<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ ci-cd - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ ci-cd - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Fri, 30 Oct 2026 15:34:15 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/ci-cd/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build Ephemeral PR Preview Environments on GKE with GitHub Actions, Ingress, and Automatic TLS ]]>
                </title>
                <description>
                    <![CDATA[ Every team that ships software eventually builds a staging environment. And every team that builds a staging environment eventually discovers the same problem. Staging is shared. One environment, many ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-ephemeral-pr-preview-environments-on-gke-with-github-actions-ingress-automatic-tls/</link>
                <guid isPermaLink="false">6ad0d6b41b1aaeb205d2d198</guid>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ gke ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Terraform ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ephemeral ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Amina Lawal ]]>
                </dc:creator>
                <pubDate>Thu, 15 Oct 2026 13:35:48 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8d8182a7-81dd-4bb8-bd62-f9e3c4175532.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every team that ships software eventually builds a staging environment. And every team that builds a staging environment eventually discovers the same problem.</p>
<p>Staging is shared. One environment, many pull requests. Someone deploys a half-finished feature to test it. Someone else deploys on top of that. A third person runs a database migration. By Thursday afternoon, nobody can tell which version of which branch is actually running, and the sentence "it works on staging" has stopped carrying any information at all.</p>
<p>The workarounds are familiar: a Slack channel where people claim staging like a meeting room, a rotating deploy schedule, or a second staging environment, then a third, each one drifting further from production configuration than the last.</p>
<p>There's a better pattern, and platform teams at companies like Vercel, Netlify, and Shopify have made it standard: <strong>give every pull request its own environment</strong>.</p>
<p>Open a PR, and a fresh, isolated copy of your application deploys automatically. Push another commit, and it updates. Merge or close the PR, and the whole thing deletes itself. No queue, no claiming, no cleanup, and no drift.</p>
<p>That's what we're building in this tutorial. Here's what it looks like from the reviewer's side. A bot comment appearing on the pull request with a live, working URL</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/366bb671-d2cb-41e5-859b-aa2242c9dd77.png" alt="A GitHub pull request thread showing an automated bot comment titled &quot;Preview environment is ready&quot;, containing a live HTTPS URL, the commit SHA, and the namespace name, with all checks passing below it." style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>By the end of this handbook, you'll have a working system where:</p>
<ul>
<li><p>Opening a pull request triggers a GitHub Actions workflow that builds a container image tagged specifically for that PR</p>
</li>
<li><p>The image is pushed to Google Artifact Registry</p>
</li>
<li><p>A dedicated Kubernetes namespace (<code>pr-42</code>, <code>pr-43</code>, and so on) is created inside a GKE cluster, with resource quotas applied before anything deploys</p>
</li>
<li><p>The application deploys into that namespace, completely isolated from every other PR's environment</p>
</li>
<li><p>A shared Ingress controller routes <code>pr-42.preview.yourdomain.com</code> to that namespace, with a valid TLS certificate issued automatically</p>
</li>
<li><p>A bot comments the live URL on the pull request, updating the same comment on every push</p>
</li>
<li><p>Pushing new commits redeploys the environment with the updated code</p>
</li>
<li><p>Closing or merging the PR deletes the namespace and everything inside it</p>
</li>
<li><p>A scheduled job catches any environment the teardown missed</p>
</li>
</ul>
<p>You'll also build the underlying infrastructure with Terraform, with three key design decisions, each with its own section.</p>
<ol>
<li><p><strong>No service account keys:</strong> GitHub Actions authenticates to Google Cloud through Workload Identity Federation. Short-lived OIDC tokens verified against a repository claim. Service account JSON keys sitting in GitHub secrets are one of the most common security problems in CI/CD pipelines today, and you don't need one.</p>
</li>
<li><p><strong>One load balancer, not one per PR:</strong> A single shared Ingress controller routes every environment by hostname, and one wildcard DNS record covers every hostname that will ever exist. The naïve version provisions a cloud load balancer per environment, which gets expensive quickly.</p>
</li>
<li><p><strong>Cleanup that assumes failure:</strong> Teardown runs when a PR closes. But a scheduled reaper also deletes namespaces whose PR is gone or that have outlived a maximum age, because even 95% effective automated cleanup still leaks, and leaks show up on the bill.</p>
</li>
</ol>
<p>Here's the architecture we're working toward:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/1a414785-4e82-4e8f-ba58-1e02a093677c.png" alt="Architecture diagram of the complete system. GitHub Actions builds and deploys to a GKE cluster through Workload Identity Federation and Artifact Registry, while browsers reach per-pull-request namespaces through wildcard DNS, a shared Google Cloud load balancer, and an NGINX Ingress controller with cert-manager handling TLS." style="display: block;" width="600" height="400" loading="lazy">

<p>The diagram traces two separate paths through the same system.</p>
<p>The solid blue path is automation. A pull request triggers GitHub Actions, which authenticates to Google Cloud through Workload Identity Federation, pushes a PR-tagged image to Artifact Registry, and deploys into a dedicated namespace inside the GKE cluster.</p>
<p>Each namespace (<code>pr-42</code>, <code>pr-43</code>, and so on) holds its own Deployment, Service, and Pod, completely isolated from every other one.</p>
<p>Two more automated pieces sit alongside it: a bot that posts and updates the preview URL on the pull request, and a scheduled reaper that finds and deletes orphaned namespaces.</p>
<p>The dashed green path is public traffic. A browser request for a preview URL resolves through a single wildcard DNS record to one Google Cloud load balancer, which hands it to a shared NGINX Ingress controller. That controller reads the request's <code>Host</code> header and routes it to the right namespace. cert-manager runs beside it, requesting certificates from Let's Encrypt so each environment gets its own valid TLS certificate.</p>
<p>The important detail is what <em>doesn't</em> multiply. One load balancer, one DNS record, and one Ingress controller serve every environment, no matter how many pull requests are open.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-step-1-set-up-the-project-structure">Step 1: Set Up the Project Structure</a></p>
</li>
<li><p><a href="#heading-step-2-write-the-application">Step 2: Write the Application</a></p>
</li>
<li><p><a href="#heading-step-3-write-the-dockerfile">Step 3: Write the Dockerfile</a></p>
</li>
<li><p><a href="#heading-step-4-provision-the-infrastructure-with-terraform">Step 4: Provision the Infrastructure with Terraform</a></p>
</li>
<li><p><a href="#heading-step-5-set-up-keyless-authentication-with-workload-identity-federation">Step 5: Set Up Keyless Authentication with Workload Identity Federation</a></p>
</li>
<li><p><a href="#heading-step-6-apply-the-terraform-configuration">Step 6: Apply the Terraform Configuration</a></p>
</li>
<li><p><a href="#heading-step-7-prove-the-lifecycle-manually">Step 7: Prove the Lifecycle Manually</a></p>
</li>
<li><p><a href="#heading-step-8-write-the-kubernetes-manifests-with-kustomize">Step 8: Write the Kubernetes Manifests with Kustomize</a></p>
</li>
<li><p><a href="#heading-step-9-build-the-spin-up-workflow">Step 9: Build the Spin-Up Workflow</a></p>
</li>
<li><p><a href="#heading-step-10-build-the-teardown-workflow">Step 10: Build the Teardown Workflow</a></p>
</li>
<li><p><a href="#heading-step-11-open-a-pull-request-and-watch-it-work">Step 11: Open a Pull Request and Watch It Work</a></p>
</li>
<li><p><a href="#heading-step-12-reserve-a-static-ip-address">Step 12: Reserve a Static IP Address</a></p>
</li>
<li><p><a href="#heading-step-13-install-the-ingress-controller">Step 13: Install the Ingress Controller</a></p>
</li>
<li><p><a href="#heading-step-14-point-wildcard-dns-at-the-load-balancer">Step 14: Point Wildcard DNS at the Load Balancer</a></p>
</li>
<li><p><a href="#heading-step-15-install-cert-manager-and-create-a-clusterissuer">Step 15: Install cert-manager and Create a ClusterIssuer</a></p>
</li>
<li><p><a href="#heading-step-16-add-an-ingress-to-the-base-manifests">Step 16: Add an Ingress to the Base Manifests</a></p>
</li>
<li><p><a href="#heading-step-17-patch-the-hostname-per-pull-request">Step 17: Patch the Hostname Per Pull Request</a></p>
</li>
<li><p><a href="#heading-step-18-add-guardrails-with-resourcequota-and-limitrange">Step 18: Add Guardrails with ResourceQuota and LimitRange</a></p>
</li>
<li><p><a href="#heading-step-19-update-the-deploy-workflow">Step 19: Update the Deploy Workflow</a></p>
</li>
<li><p><a href="#heading-step-20-comment-the-url-on-the-pull-request">Step 20: Comment the URL on the Pull Request</a></p>
</li>
<li><p><a href="#heading-step-21-build-the-reaper">Step 21: Build the Reaper</a></p>
</li>
<li><p><a href="#heading-step-22-test-the-whole-thing">Step 22: Test the Whole Thing</a></p>
</li>
<li><p><a href="#heading-what-this-costs">What This Costs</a></p>
</li>
<li><p><a href="#heading-cleanup">Cleanup</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-project-file-structure">Project File Structure</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, make sure you have the following ready:</p>
<ul>
<li><p><strong>A Google Cloud project</strong> with billing enabled. If you don't have one, create it at <a href="https://console.cloud.google.com">console.cloud.google.com</a>. Running this tutorial costs roughly $5–10 depending on how long you leave the cluster up. Don't skip the cleanup section at the end.</p>
</li>
<li><p><code>gcloud</code> <strong>CLI</strong> installed and authenticated. Run <code>gcloud auth login</code> to sign in, and <code>gcloud config set project YOUR_PROJECT_ID</code> to point it at your project.</p>
</li>
<li><p><strong>Terraform</strong> version 1.5 or later. Check with <code>terraform version</code>.</p>
</li>
<li><p><code>kubectl</code> installed. This is the CLI for interacting with Kubernetes clusters.</p>
</li>
<li><p><strong>Docker</strong> installed locally, for testing the build before you hand it to CI.</p>
</li>
<li><p><strong>A GitHub repository</strong> that you own. This needs to be a repository you can push branches to and open pull requests against, since that's the trigger for everything we build.</p>
</li>
<li><p><strong>Basic familiarity with Docker</strong> (images, layers, Dockerfile), <strong>Kubernetes</strong> (pods, deployments, services, namespaces), and <strong>GitHub Actions</strong> (workflows, jobs, steps). You don't need to be an expert, but you should know what these things are.</p>
</li>
<li><p><strong>Helm</strong> version 3, for installing the Ingress controller and cert-manager. Check with <code>helm version</code>.</p>
</li>
<li><p><strong>A domain name you control</strong>, with the ability to add DNS records. It doesn't matter where it's registered. You'll dedicate one subdomain to previews, like <code>preview.yourdomain.com</code>, and every environment lives beneath it.</p>
</li>
</ul>
<p>A note on the domain requirement: this is the part of the project that can't be faked. Let's Encrypt needs to verify that you control the domain before it will issue a certificate, and it verifies this by making a real HTTP request from the public internet. A <code>.local</code> domain, an entry in <code>/etc/hosts</code>, or a domain you don't own won't work.</p>
<p>If you don't have a spare domain, a <code>.dev</code> or <code>.xyz</code> costs a few pounds a year and is worth having for exactly this kind of project.</p>
<p>One more thing worth bringing your attention to early: you'll also need the <code>gke-gcloud-auth-plugin</code>. Recent versions of <code>gcloud</code> prompt you to install it the first time you connect to a GKE cluster. You can install it ahead of time with:</p>
<pre><code class="language-bash">gcloud components install gke-gcloud-auth-plugin
</code></pre>
<h2 id="heading-step-1-set-up-the-project-structure">Step 1: Set Up the Project Structure</h2>
<p>Let's lay out the directories first, so you have a clear mental model of where everything lives.</p>
<pre><code class="language-bash">mkdir -p pr-preview-envs/{app,terraform,k8s/base,.github/workflows}
cd pr-preview-envs
git init
</code></pre>
<p>Here's what each directory holds:</p>
<ul>
<li><p><code>app/</code>: the Go application and its Dockerfile</p>
</li>
<li><p><code>terraform/</code>: infrastructure as code: the GKE cluster, Artifact Registry, and Workload Identity Federation setup</p>
</li>
<li><p><code>k8s/base/</code>: the base Kubernetes manifests that every preview environment is built from</p>
</li>
<li><p><code>.github/workflows/</code>: the two GitHub Actions workflows that create and destroy environments</p>
</li>
</ul>
<p>Connect this to a GitHub repository now, because the Workload Identity Federation setup in Step 5 needs to know your repository's full name (<code>owner/repo</code>):</p>
<pre><code class="language-bash">git remote add origin https://github.com/YOUR_USERNAME/pr-preview-envs.git
</code></pre>
<h2 id="heading-step-2-write-the-application">Step 2: Write the Application</h2>
<p>We need something to deploy. We'll use <strong>Go</strong>, for three reasons that matter to this project specifically.</p>
<p>First, Go compiles to a single statically-linked binary. There's no runtime to install and no interpreter, which means very small container images, and small images mean fast pushes and fast pulls, which matters a lot when you're building an image on <em>every single commit to every open PR</em>.</p>
<p>Second, Go's <code>-ldflags -X</code> mechanism lets us inject values into the binary at build time. We'll use this to bake the PR number and Git commit SHA directly into the application, so the running app can tell us exactly which build it is.</p>
<p>And lastly, build times are fast, and CI minutes aren't free.</p>
<p>Create <code>app/main.go</code>:</p>
<pre><code class="language-go">package main

import (
	"fmt"
	"net/http"
	"os"
)

// These values are empty at compile time and get injected by the
// linker via -ldflags -X during the Docker build. See the Dockerfile.
var (
	prNumber = "local"
	gitSha   = "dev"
)

func handler(w http.ResponseWriter, r *http.Request) {
	// Kubernetes injects these via the Downward API — see deployment.yaml
	namespace := os.Getenv("POD_NAMESPACE")
	podName := os.Getenv("POD_NAME")

	if namespace == "" {
		namespace = "not-in-kubernetes"
	}
	if podName == "" {
		podName, _ = os.Hostname()
	}

	fmt.Fprintf(w, "Preview environment is live\n\n")
	fmt.Fprintf(w, "PR number  : %s\n", prNumber)
	fmt.Fprintf(w, "Git SHA    : %s\n", gitSha)
	fmt.Fprintf(w, "Namespace  : %s\n", namespace)
	fmt.Fprintf(w, "Pod name   : %s\n", podName)
}

func healthz(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
	fmt.Fprintln(w, "ok")
}

func main() {
	http.HandleFunc("/", handler)
	http.HandleFunc("/healthz", healthz)

	fmt.Println("Server starting on port 8080...")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		fmt.Fprintf(os.Stderr, "server error: %v\n", err)
		os.Exit(1)
	}
}
</code></pre>
<p>Initialize the Go module from inside <code>app/</code>:</p>
<pre><code class="language-bash">cd app
go mod init preview-app
</code></pre>
<p>Let's walk through what this code does and why each piece is here.</p>
<p><code>var prNumber = "local"</code> <strong>and</strong> <code>var gitSha = "dev"</code> are package-level string variables with default values. The defaults are what you'll see if you run the binary locally without going through the Docker build.</p>
<p>During the CI build, the Go linker overwrites them with the real PR number and commit SHA. This is the single most useful debugging feature in the whole project: when you hit a preview environment, the app itself tells you exactly which commit it was built from. There's no guessing.</p>
<p><code>os.Getenv("POD_NAMESPACE")</code> <strong>and</strong> <code>os.Getenv("POD_NAME")</code> read environment variables that Kubernetes injects into the container using something called the <strong>Downward API</strong>. We'll configure that in the deployment manifest in Step 8.</p>
<p>The Downward API lets a pod read metadata about itself (its own name, namespace, and labels) without needing any Kubernetes API credentials. It's the cleanest way for an app to know where it's running.</p>
<p><strong>The</strong> <code>handler</code> <strong>function</strong> is registered on the root path <code>/</code>. It writes the PR number, commit SHA, namespace, and pod name to the response. It's four pieces of information that together answer "what exactly am I looking at right now?"</p>
<p><strong>The</strong> <code>healthz</code> <strong>function</strong> is registered on <code>/healthz</code>, and returns HTTP 200 with the text <code>ok</code>. Kubernetes uses this endpoint for liveness and readiness probes, which we'll wire up in the deployment manifest.</p>
<p><code>http.ListenAndServe(":8080", nil)</code> starts the server on port 8080. If it fails to start, it prints the error and exits with a non-zero code, so Kubernetes knows the container is broken and can restart it.</p>
<p>Test it locally before moving on:</p>
<pre><code class="language-bash">go run main.go
</code></pre>
<p>In a second terminal:</p>
<pre><code class="language-bash">curl http://localhost:8080
</code></pre>
<p>You should see <code>PR number : local</code> and <code>Git SHA : dev</code>, since you're running outside the Docker build. That's the expected local behaviour.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/1ee23b56-dcd8-48f1-a0c8-5c1e3a908d2c.png" alt="Terminal output from curl showing the application reporting PR number &quot;local&quot; and Git SHA &quot;dev&quot; when run outside the Docker build" style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-step-3-write-the-dockerfile">Step 3: Write the Dockerfile</h2>
<p>Create <code>app/Dockerfile</code>:</p>
<pre><code class="language-dockerfile"># -----------------------------------------------------------
# Stage 1: Build
# -----------------------------------------------------------
FROM golang:1.27-alpine AS builder
#Ensure the version matches what is in your go.mod file

# These are injected by the CI pipeline at build time
ARG PR_NUMBER=local
ARG GIT_SHA=dev

WORKDIR /app

# Copy go.mod first so Docker can cache the dependency layer
COPY go.mod .
RUN go mod download

COPY main.go .

# -w -s strips debug symbols (~30% smaller binary)
# -X overwrites package-level string variables at link time
RUN CGO_ENABLED=0 go build \
    -ldflags="-w -s -X main.prNumber=${PR_NUMBER} -X main.gitSha=${GIT_SHA}" \
    -o server main.go

# -----------------------------------------------------------
# Stage 2: Runtime
# -----------------------------------------------------------
FROM alpine:latest

RUN addgroup -S appgroup &amp;&amp; adduser -S appuser -G appgroup
USER appuser

WORKDIR /app
COPY --from=builder /app/server .

EXPOSE 8080
CMD ["./server"]
</code></pre>
<p>There are a few things happening here worth understanding properly.</p>
<h3 id="heading-1-the-ldflags-x-injection">1. The <code>-ldflags -X</code> Injection</h3>
<pre><code class="language-plaintext">-X main.prNumber=${PR_NUMBER}
</code></pre>
<p>The <code>-X</code> linker flag sets the value of a package-level string variable at link time. The syntax is <code>-X importpath.name=value</code>. Since our variables live in <code>package main</code>, the import path is just <code>main</code>.</p>
<p>This only works on <code>string</code> variables that are already declared in the source. You can't create new variables this way, and you can't inject into an <code>int</code> or a <code>bool</code>. That's why <code>prNumber</code> is a string in <code>main.go</code> even though it holds a number.</p>
<p>Why do this instead of just passing an environment variable through the deployment manifest? Because build metadata belongs to the <em>image</em>, not the <em>deployment</em>. If you pass it as an env var, someone can deploy image <code>abc123</code> with an env var claiming it's commit <code>def456</code>, and the app will happily lie to you. Baking it into the binary means the image can only ever report what it actually is.</p>
<h3 id="heading-the-cgoenabled0-flag">The <code>CGO_ENABLED=0</code> Flag</h3>
<p>By default, Go can link against C libraries. When it does, the resulting binary depends on the system's C library being present at runtime. Alpine uses <code>musl</code> instead of the more common <code>glibc</code>, and that mismatch is a classic source of "works in the builder stage, crashes in the runtime stage" errors.</p>
<p>Setting <code>CGO_ENABLED=0</code> forces a pure-Go, fully static binary with no external library dependencies. It will run on any Linux system, including a <code>scratch</code> image with nothing in it at all.</p>
<h3 id="heading-copy-gomod-before-copy-maingo"><code>COPY go.mod .</code> Before <code>COPY main.go .</code></h3>
<p>Docker builds images layer by layer and caches each layer. By copying only <code>go.mod</code> first and running <code>go mod download</code> before copying the source code, we create a cached dependency layer.</p>
<p>On subsequent builds (and remember, this pipeline builds on <em>every commit to every open PR</em>), Docker skips the dependency download entirely as long as <code>go.mod</code> hasn't changed. On a real project with dozens of dependencies, this is the difference between a 20-second build and a 3-minute build, multiplied by every push your team makes.</p>
<h3 id="heading-the-non-root-user">The Non-Root User</h3>
<pre><code class="language-dockerfile">RUN addgroup -S appgroup &amp;&amp; adduser -S appuser -G appgroup
USER appuser
</code></pre>
<p>Containers run as root by default. If an attacker finds a vulnerability in your application, running as root inside the container gives them significantly more room to escalate. Creating an unprivileged user and switching to it limits the blast radius. It costs you two lines, and there's no reason not to do it.</p>
<p>Build and test it locally:</p>
<pre><code class="language-bash">docker build --build-arg PR_NUMBER=99 --build-arg GIT_SHA=abc1234 -t preview-app:test .
docker run -p 8080:8080 preview-app:test
</code></pre>
<p>Then <code>curl http://localhost:8080</code> in another terminal. You should now see <code>PR number : 99</code> and <code>Git SHA : abc1234</code>. The injection works.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/bce5bd63-02fc-4ee6-9631-34f810fad546.png" alt="Terminal output from curl showing the application reporting PR number 99 and Git SHA abc1234, injected at build time by the Docker build arguments" style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-step-4-provision-the-infrastructure-with-terraform">Step 4: Provision the Infrastructure with Terraform</h2>
<p>Now we'll build the cloud infrastructure. We're using Terraform rather than a series of <code>gcloud</code> commands for a reason that becomes obvious the moment something goes wrong: with Terraform, <code>terraform destroy</code> cleans up everything you created, in the right order, with no orphaned resources quietly billing you.</p>
<p>Create <code>terraform/variables.tf</code>:</p>
<pre><code class="language-hcl">variable "project_id" {
  description = "GCP project ID"
  type        = string
}

variable "region" {
  description = "GCP region"
  type        = string
  default     = "europe-west2"
}

variable "zone" {
  description = "GCP zone. A zonal cluster is cheaper than a regional one."
  type        = string
  default     = "europe-west2-a"
}

variable "cluster_name" {
  description = "Name of the GKE cluster"
  type        = string
  default     = "pr-preview-cluster"
}

variable "github_repo" {
  description = "GitHub repository in owner/repo format, e.g. amina/pr-preview-envs"
  type        = string
}

variable "machine_type" {
  description = "Node machine type"
  type        = string
  default     = "e2-standard-2"
}

variable "node_count" {
  description = "Number of nodes in the pool"
  type        = number
  default     = 2
}
</code></pre>
<p>Now create <code>terraform/main.tf</code>:</p>
<pre><code class="language-hcl">terraform {
  required_version = "&gt;= 1.5"
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~&gt; 5.0"
    }
  }
}

provider "google" {
  project = var.project_id
  region  = var.region
}

# Look up the project so we can reference its numeric project number,
# which Workload Identity Federation requires (it doesn't accept the
# human-readable project ID).
data "google_project" "current" {}

# -----------------------------------------------------------
# Enable required APIs
# -----------------------------------------------------------
resource "google_project_service" "apis" {
  for_each = toset([
    "container.googleapis.com",
    "artifactregistry.googleapis.com",
    "iamcredentials.googleapis.com",
    "sts.googleapis.com",
  ])

  service            = each.value
  disable_on_destroy = false
}

# -----------------------------------------------------------
# Artifact Registry — where preview images live
# -----------------------------------------------------------
resource "google_artifact_registry_repository" "preview" {
  location      = var.region
  repository_id = "pr-preview"
  description   = "Container images for PR preview environments"
  format        = "DOCKER"

  depends_on = [google_project_service.apis]
}

# -----------------------------------------------------------
# GKE cluster
# -----------------------------------------------------------
resource "google_container_cluster" "primary" {
  name     = var.cluster_name
  location = var.zone

  # Create the cluster with a throwaway default pool, then remove it
  # and manage nodes through a separate node pool resource. This is the
  # standard pattern — it lets you resize or upgrade the node pool
  # without recreating the cluster control plane.
  remove_default_node_pool = true
  initial_node_count       = 1

  networking_mode = "VPC_NATIVE"
  ip_allocation_policy {}

  # Set to true before you use this for anything real.
  deletion_protection = false

  depends_on = [google_project_service.apis]
}

resource "google_container_node_pool" "primary_nodes" {
  name       = "${var.cluster_name}-pool"
  location   = var.zone
  cluster    = google_container_cluster.primary.name
  node_count = var.node_count

  node_config {
    machine_type = var.machine_type
    disk_size_gb = 50

    oauth_scopes = [
      "https://www.googleapis.com/auth/cloud-platform",
    ]

    labels = {
      purpose = "pr-preview-envs"
    }
  }

  management {
    auto_repair  = true
    auto_upgrade = true
  }
}
</code></pre>
<p>There are a few things that're worth calling out here.</p>
<p><code>data "google_project" "current"</code> isn't creating anything. It's a data source, which reads existing information from your provider. We need it because Workload Identity Federation identifies your project by its <strong>numeric project number</strong>, not the project ID you type in the console. Rather than making you look it up manually and paste it in, Terraform fetches it for you.</p>
<p><code>for_each</code> <strong>on</strong> <code>google_project_service</code>: instead of writing four nearly identical resource blocks to enable four APIs, <code>for_each</code> iterates over a set and creates one resource per item. <code>disable_on_destroy = false</code> means <code>terraform destroy</code> won't turn these APIs off, which is what you want, because other things in your project might be using them.</p>
<p><code>remove_default_node_pool = true</code>: GKE insists on creating a default node pool when you create a cluster. Setting this immediately deletes it, and we define our own node pool as a separate resource. The benefit is that node pool changes (resizing, changing machine type, upgrading) don't force a change to the cluster resource itself.</p>
<p><code>node_count = 2</code> <strong>with</strong> <code>e2-standard-2</code>: two nodes with 2 vCPU and 8GB each. Preview environments are small, but you'll have several running at once, plus GKE's own system pods. Two nodes give you comfortable headroom. If you plan to keep more than about eight preview environments alive simultaneously, bump this up.</p>
<p><code>deletion_protection = false</code>: GKE added this as a guard against accidentally destroying production clusters. We're disabling it because this is a tutorial cluster you'll delete at the end. If you adapt this for real use, set it to <code>true</code>.</p>
<h2 id="heading-step-5-set-up-keyless-authentication-with-workload-identity-federation">Step 5: Set Up Keyless Authentication with Workload Identity Federation</h2>
<p>This is the section that matters most, so let's take it slowly.</p>
<h3 id="heading-the-problem-with-service-account-keys">The Problem With Service Account Keys</h3>
<p>GitHub Actions needs permission to push images to Artifact Registry and deploy to your GKE cluster. The way most tutorials handle this is:</p>
<ol>
<li><p>Create a service account in GCP</p>
</li>
<li><p>Generate a JSON key for it</p>
</li>
<li><p>Paste that JSON into a GitHub secret called <code>GCP_SA_KEY</code></p>
</li>
<li><p>Load it in the workflow</p>
</li>
</ol>
<p>This works, but it's also a security problem, and here's why:</p>
<p>First, <strong>the key never expires.</strong> A service account JSON key is a long-lived credential. It's valid until someone explicitly revokes it, which in practice means it's valid forever, because nobody remembers to rotate them.</p>
<p>Second, <strong>the key is a bearer token.</strong> Anyone who obtains that JSON file <em>is</em> your service account. No second factor, IP restriction, or expiry. Copy the file, and use it from anywhere in the world.</p>
<p>Third, <strong>the blast radius is enormous.</strong> GitHub secrets are exposed to workflows. A compromised third-party action, a malicious dependency in your build, or a workflow modified in a pull request can exfiltrate that secret. Once it's out, an attacker has persistent access to your cloud project.</p>
<p>This isn't theoretical. Leaked service account keys are one of the most common root causes in cloud security incidents.</p>
<h3 id="heading-how-workload-identity-federation-fixes-it">How Workload Identity Federation Fixes It</h3>
<p><strong>Workload Identity Federation</strong> removes the long-lived key entirely. Here's the flow:</p>
<ol>
<li><p>GitHub Actions generates a short-lived <strong>OIDC token</strong> for the running workflow. This token is signed by GitHub and contains verified claims: which repository is running, which branch, which workflow, and which actor.</p>
</li>
<li><p>Your workflow presents that token to Google Cloud's Security Token Service.</p>
</li>
<li><p>Google checks the token's signature against GitHub's public keys, and checks the claims against rules <strong>you</strong> configured, specifically that the token came from your repository and no other.</p>
</li>
<li><p>If everything matches, Google issues a short-lived access token, typically valid for about an hour.</p>
</li>
<li><p>The workflow uses that token, and it expires shortly after.</p>
</li>
</ol>
<p>There's no secret stored anywhere. Nothing to leak, rotate, or revoke. If someone forks your repository and runs the same workflow, the OIDC token they generate will carry <em>their</em> repository name in its claims, Google's check will fail, and they get nothing.</p>
<p>Add this to <code>terraform/main.tf</code>:</p>
<pre><code class="language-hcl"># -----------------------------------------------------------
# Service account that GitHub Actions will impersonate
# -----------------------------------------------------------
resource "google_service_account" "github_actions" {
  account_id   = "github-actions-preview"
  display_name = "GitHub Actions — PR Preview Environments"
}

# Permission to push images to Artifact Registry
resource "google_project_iam_member" "artifact_writer" {
  project = var.project_id
  role    = "roles/artifactregistry.writer"
  member  = "serviceAccount:${google_service_account.github_actions.email}"
}

# Permission to create namespaces and deploy workloads in GKE.
# container.developer grants full access to Kubernetes API objects,
# but NOT permission to modify the cluster itself.
resource "google_project_iam_member" "gke_developer" {
  project = var.project_id
  role    = "roles/container.developer"
  member  = "serviceAccount:${google_service_account.github_actions.email}"
}

# -----------------------------------------------------------
# Workload Identity Federation
# -----------------------------------------------------------
resource "google_iam_workload_identity_pool" "github" {
  workload_identity_pool_id = "github-pool"
  display_name              = "GitHub Actions Pool"
  description               = "Identity pool for GitHub Actions OIDC"

  depends_on = [google_project_service.apis]
}

resource "google_iam_workload_identity_pool_provider" "github" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.github.workload_identity_pool_id
  workload_identity_pool_provider_id = "github-provider"
  display_name                       = "GitHub OIDC Provider"

  # Map claims from the GitHub OIDC token to Google attributes
  attribute_mapping = {
    "google.subject"       = "assertion.sub"
    "attribute.actor"      = "assertion.actor"
    "attribute.repository" = "assertion.repository"
  }

  # CRITICAL: without this condition, ANY GitHub repository on the
  # internet could authenticate against your pool.
  attribute_condition = "assertion.repository == '${var.github_repo}'"

  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }
}

# Allow tokens from your specific repo to impersonate the service account
resource "google_service_account_iam_member" "github_impersonation" {
  service_account_id = google_service_account.github_actions.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/projects/${data.google_project.current.number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.github.workload_identity_pool_id}/attribute.repository/${var.github_repo}"
}
</code></pre>
<p>Let's break down the parts that trip people up.</p>
<p><code>attribute_mapping</code>: GitHub's OIDC token contains a set of claims. This block tells Google which claims to read and what to call them internally. <code>assertion.sub</code> is the token's subject (something like <code>repo:owner/name:ref:refs/heads/main</code>). <code>assertion.repository</code> is the full repository name. <code>google.subject</code> is required, while the others are optional attributes you can then write rules against.</p>
<p><code>attribute_condition</code>: read this line twice, because getting it wrong is a serious vulnerability:</p>
<pre><code class="language-hcl">attribute_condition = "assertion.repository == '${var.github_repo}'"
</code></pre>
<p>Without this condition, your identity pool trusts <em>the GitHub OIDC issuer</em>, which means it trusts every repository on GitHub. Anyone who knows your pool's resource path (which isn't secret and appears in workflow files) could authenticate and impersonate your service account.</p>
<p>This condition narrows the trust to exactly one repository. Google now requires an attribute condition when you map <code>assertion.repository</code>, precisely because so many people got this wrong.</p>
<p><code>principalSet://...</code>: this is the identity being granted the <code>workloadIdentityUser</code> role. Read it right to left: any principal in the <code>github-pool</code> whose <code>attribute.repository</code> equals your repo may impersonate this service account. Note that it uses <code>data.google_project.current.number</code>, which is the numeric project number, not the project ID. Using the project ID here produces an error that doesn't clearly explain what's wrong, and it's one of the most common mistakes with WIF.</p>
<p><strong>Why</strong> <code>roles/container.developer</code> <strong>and not</strong> <code>roles/container.admin</code><strong>?</strong> <code>container.developer</code> grants full access to Kubernetes API objects inside clusters: creating namespaces, applying deployments, deleting resources. <code>container.admin</code> additionally allows modifying the clusters themselves: resizing node pools, upgrading, deleting the cluster entirely.</p>
<p>Our workflow never needs to touch the cluster's own configuration, so it shouldn't have permission to. This is least privilege in practice: grant exactly what the job requires and nothing beyond it.</p>
<p>Now create <code>terraform/outputs.tf</code>:</p>
<pre><code class="language-hcl">output "cluster_name" {
  value = google_container_cluster.primary.name
}

output "cluster_zone" {
  value = var.zone
}

output "artifact_registry_url" {
  description = "Base URL for pushing images"
  value       = "${var.region}-docker.pkg.dev/${var.project_id}/${google_artifact_registry_repository.preview.repository_id}"
}

output "workload_identity_provider" {
  description = "Paste this into the GitHub Actions workflow"
  value       = "projects/${data.google_project.current.number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.github.workload_identity_pool_id}/providers/${google_iam_workload_identity_pool_provider.github.workload_identity_pool_provider_id}"
}

output "service_account_email" {
  description = "Paste this into the GitHub Actions workflow"
  value       = google_service_account.github_actions.email
}

output "get_credentials_command" {
  value = "gcloud container clusters get-credentials ${google_container_cluster.primary.name} --zone ${var.zone} --project ${var.project_id}"
}
</code></pre>
<p>Those outputs aren't decoration. <code>workload_identity_provider</code> and <code>service_account_email</code> are the two values you'll paste into your GitHub Actions workflow, and both are tedious to construct by hand.</p>
<h2 id="heading-step-6-apply-the-terraform-configuration">Step 6: Apply the Terraform Configuration</h2>
<p>From inside the <code>terraform/</code> directory:</p>
<pre><code class="language-bash">terraform init
</code></pre>
<p>This downloads the Google provider and sets up the backend. You should see "Terraform has been successfully initialized!"</p>
<p>Now preview what will be created:</p>
<pre><code class="language-bash">terraform plan \
  -var="project_id=YOUR_PROJECT_ID" \
  -var="github_repo=YOUR_USERNAME/pr-preview-envs"
</code></pre>
<p>Read the plan output. Terraform tells you exactly what it intends to create before it creates anything. You should see roughly a dozen resources to add and none to change or destroy.</p>
<p>If that looks right:</p>
<pre><code class="language-bash">terraform apply \
  -var="project_id=YOUR_PROJECT_ID" \
  -var="github_repo=YOUR_USERNAME/pr-preview-envs"
</code></pre>
<p>Type <code>yes</code> when prompted. Cluster creation takes about five minutes.</p>
<p><strong>Tip:</strong> typing those <code>-var</code> flags repeatedly gets old fast. Create a file called <code>terraform.tfvars</code> containing <code>project_id = "your-project-id"</code> and <code>github_repo = "you/pr-preview-envs"</code>, and Terraform picks it up automatically. Add it to <code>.gitignore</code>.</p>
<p>When it finishes, Terraform prints your outputs. <strong>Save these</strong>, as you'll need them in Step 9:</p>
<pre><code class="language-plaintext">artifact_registry_url = "europe-west2-docker.pkg.dev/your-project/pr-preview"
service_account_email = "github-actions-preview@your-project.iam.gserviceaccount.com"
workload_identity_provider = "projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github-provider"
</code></pre>
<p>You can print them again any time with <code>terraform output</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/23a9ff48-3691-41c5-876f-83cfdf226909.png" alt="Output of Terraform apply showing credentials needed" style="display: block;" width="600" height="400" loading="lazy">

<p>Connect <code>kubectl</code> to the new cluster using the command from the <code>get_credentials_command</code> output:</p>
<pre><code class="language-bash">gcloud container clusters get-credentials pr-preview-cluster \
  --zone europe-west2-a \
  --project YOUR_PROJECT_ID
</code></pre>
<p>Verify:</p>
<pre><code class="language-bash">kubectl get nodes
</code></pre>
<p>You should see two nodes in <code>Ready</code> state.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/cbfcf36b-9e9a-4d38-8165-3c1327e3b0f6.png" alt="Output of kubectl get nodes showing two GKE nodes in Ready status" style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-step-7-prove-the-lifecycle-manually">Step 7: Prove the Lifecycle Manually</h2>
<p>Before automating anything, do it by hand once.</p>
<p>This isn't busywork. Automation is fundamentally just a script running commands you could have typed. If you don't know what the commands are, you can't debug the automation when it fails...and it will fail, usually at 11 pm before a demo. Ten minutes here saves hours later.</p>
<p>Create a namespace:</p>
<pre><code class="language-bash">kubectl create namespace pr-test
</code></pre>
<p>Deploy a placeholder into it:</p>
<pre><code class="language-bash">kubectl create deployment hello \
  --image=nginx:1.27-alpine \
  --namespace=pr-test
</code></pre>
<p>Check it:</p>
<pre><code class="language-bash">kubectl get pods --namespace=pr-test
</code></pre>
<p>Now the important part – prove the isolation:</p>
<pre><code class="language-bash">kubectl get pods --all-namespaces | grep hello
</code></pre>
<p>The pod exists only in <code>pr-test</code>. Nothing in <code>default</code> or any other namespace knows about it. That's the property the entire project is built on: a namespace is a boundary, and everything inside one PR's namespace is invisible to every other.</p>
<p>Now tear it down:</p>
<pre><code class="language-bash">kubectl delete namespace pr-test
</code></pre>
<p>Watch what happens:</p>
<pre><code class="language-bash">kubectl get namespace pr-test
</code></pre>
<p>For a few seconds, you'll see <code>Terminating</code>, then the namespace disappears entirely. Deleting a namespace deletes every resource inside it: deployments, pods, services, config maps, secrets, everything. One command, complete cleanup.</p>
<p>That single property is why namespace-per-PR is such a good fit for preview environments. Teardown isn't a careful sequence of deletions you have to get right. It's one command that can't leave anything behind.</p>
<h2 id="heading-step-8-write-the-kubernetes-manifests-with-kustomize">Step 8: Write the Kubernetes Manifests with Kustomize</h2>
<p>Every preview environment deploys the same application. The only differences between <code>pr-42</code> and <code>pr-43</code> are the namespace and the image tag.</p>
<p>This is exactly the problem <strong>Kustomize</strong> solves. You write the manifests once as a <strong>base</strong>, then apply small, declarative changes on top of it (an <strong>overlay</strong>) without templating, string substitution, or <code>sed</code> scripts mangling your YAML.</p>
<p>Kustomize is built into <code>kubectl</code> (that's the <code>-k</code> flag), so there's nothing extra to install.</p>
<p>Create <code>k8s/base/deployment.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: preview-app
  labels:
    app: preview-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: preview-app
  template:
    metadata:
      labels:
        app: preview-app
    spec:
      containers:
        - name: preview-app
          # This is a placeholder. The overlay replaces it with the
          # real registry path and PR-specific tag.
          image: preview-app
          ports:
            - containerPort: 8080
          env:
            # Downward API: the pod learns its own identity
            - name: POD_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 2
            periodSeconds: 5
          resources:
            requests:
              cpu: "50m"
              memory: "64Mi"
            limits:
              cpu: "200m"
              memory: "128Mi"
</code></pre>
<p>Create <code>k8s/base/service.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Service
metadata:
  name: preview-app
spec:
  selector:
    app: preview-app
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
  type: ClusterIP
</code></pre>
<p>Create <code>k8s/base/kustomization.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - deployment.yaml
  - service.yaml
</code></pre>
<p>There are some deliberate choices here.</p>
<p><code>replicas: 1</code>: production runs multiple replicas for availability. A preview environment doesn't need high availability. One replica per environment, multiplied across every open PR, keeps your node count and your bill sane.</p>
<p><code>image: preview-app</code>: a bare placeholder with no registry and no tag. It would never pull as-is. Kustomize's image transformer matches on this name and replaces it wholesale, so the base manifest never needs to know your registry URL.</p>
<p><strong>The Downward API</strong> <code>env</code> <strong>block</strong>: this is where <code>POD_NAMESPACE</code> and <code>POD_NAME</code> come from. <code>fieldRef</code> reads a field from the pod's own spec at runtime and exposes it as an environment variable. No API calls, credentials, or service account needed.</p>
<p><code>type: ClusterIP</code>: internal-only. Each <code>LoadBalancer</code> service provisions a real Google Cloud load balancer with a real hourly cost and a public IP. Doing that per PR would be expensive and wasteful. We'll reach the app with <code>kubectl port-forward</code> for now, and in Step 13 a single shared Ingress controller takes over, serving every environment through one load balancer.</p>
<p><strong>Resource requests and limits</strong>: <code>requests</code> reserve capacity so the scheduler knows how much room a pod needs. <code>limits</code> cap what it can consume. Without limits, one runaway preview environment could starve every other one on the node. With many short-lived environments coming and going, this isn't optional.</p>
<p>Test the base renders correctly:</p>
<pre><code class="language-bash">kubectl kustomize k8s/base
</code></pre>
<p>That prints the rendered YAML without applying it. This is useful for debugging.</p>
<h2 id="heading-step-9-build-the-spin-up-workflow">Step 9: Build the Spin-Up Workflow</h2>
<p>Now we connect everything.</p>
<p>Create <code>.github/workflows/preview-deploy.yml</code>:</p>
<pre><code class="language-yaml">name: Deploy PR Preview

on:
  pull_request:
    types: [opened, synchronize, reopened]

# If someone pushes three commits quickly, cancel the older runs.
# Without this, three deploys race each other into the same namespace
# and whichever finishes last wins — which may not be the newest commit.
concurrency:
  group: preview-${{ github.event.pull_request.number }}
  cancel-in-progress: true

permissions:
  contents: read
  id-token: write   # REQUIRED for Workload Identity Federation
  pull-requests: write

env:
  PROJECT_ID: YOUR_PROJECT_ID
  REGION: europe-west2
  CLUSTER_NAME: pr-preview-cluster
  CLUSTER_ZONE: europe-west2-a
  REPOSITORY: pr-preview
  IMAGE_NAME: preview-app

jobs:
  deploy:
    name: Build and deploy preview
    runs-on: ubuntu-latest

    steps:
      - name: Check out code
        uses: actions/checkout@v4

      - name: Set environment variables
        run: |
          PR_NUMBER=${{ github.event.pull_request.number }}
          SHORT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7)

          echo "PR_NUMBER=${PR_NUMBER}"   &gt;&gt; $GITHUB_ENV
          echo "SHORT_SHA=${SHORT_SHA}"   &gt;&gt; $GITHUB_ENV
          echo "NAMESPACE=pr-${PR_NUMBER}" &gt;&gt; $GITHUB_ENV
          echo "IMAGE_TAG=pr-${PR_NUMBER}-${SHORT_SHA}" &gt;&gt; $GITHUB_ENV
          echo "IMAGE_BASE=${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}" &gt;&gt; $GITHUB_ENV

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
          service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}

      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v2

      - name: Configure Docker for Artifact Registry
        run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet

      - name: Build and push image
        run: |
          docker build \
            --build-arg PR_NUMBER="${PR_NUMBER}" \
            --build-arg GIT_SHA="${SHORT_SHA}" \
            -t "${IMAGE_BASE}:${IMAGE_TAG}" \
            ./app

          docker push "${IMAGE_BASE}:${IMAGE_TAG}"

      - name: Get GKE credentials
        uses: google-github-actions/get-gke-credentials@v2
        with:
          cluster_name: ${{ env.CLUSTER_NAME }}
          location: ${{ env.CLUSTER_ZONE }}

      - name: Create namespace
        run: |
          # --dry-run=client | apply makes this idempotent.
          # Running it on an existing namespace is a no-op, not an error.
          kubectl create namespace "${NAMESPACE}" \
            --dry-run=client -o yaml | kubectl apply -f -

      - name: Generate Kustomize overlay
        run: |
          mkdir -p k8s/overlays/"${NAMESPACE}"
          cat &gt; k8s/overlays/"${NAMESPACE}"/kustomization.yaml &lt;&lt;EOF
          apiVersion: kustomize.config.k8s.io/v1beta1
          kind: Kustomization

          namespace: ${NAMESPACE}

          resources:
            - ../../base

          images:
            - name: preview-app
              newName: ${IMAGE_BASE}
              newTag: ${IMAGE_TAG}
          EOF

          echo "--- Rendered manifest ---"
          kubectl kustomize k8s/overlays/"${NAMESPACE}"

      - name: Deploy
        run: kubectl apply -k k8s/overlays/"${NAMESPACE}"

      - name: Wait for rollout
        run: |
          kubectl rollout status deployment/preview-app \
            --namespace="${NAMESPACE}" \
            --timeout=180s

      - name: Show deployment details
        run: |
          echo "Namespace: ${NAMESPACE}"
          kubectl get pods,svc --namespace="${NAMESPACE}"
</code></pre>
<h3 id="heading-add-the-two-secrets">Add the Two Secrets</h3>
<p>Go to your repository on GitHub and go to <strong>Settings</strong>, <strong>Secrets and variables</strong>, <strong>Actions</strong>, and <strong>New repository secret</strong>. Add:</p>
<table>
<thead>
<tr>
<th>Secret name</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><code>WIF_PROVIDER</code></td>
<td>The <code>workload_identity_provider</code> output from Terraform</td>
</tr>
<tr>
<td><code>WIF_SERVICE_ACCOUNT</code></td>
<td>The <code>service_account_email</code> output from Terraform</td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/3badb7bc-a162-4db7-ae18-5c93b492c5cc.png" alt="GitHub repository secrets page listing WIF_PROVIDER and WIF_SERVICE_ACCOUNT" style="display: block;" width="600" height="400" loading="lazy">

<p>Neither of these is a credential. The provider path and service account email are identifiers, not secrets. They grant nothing on their own, because the OIDC token and the attribute condition are what actually control access. Storing them as secrets is just tidiness. Update <code>PROJECT_ID</code> and the region values in the workflow's <code>env:</code> block to match your setup.</p>
<h3 id="heading-understanding-the-workflow">Understanding the Workflow</h3>
<p><code>on: pull_request: types: [opened, synchronize, reopened]</code>: these are three distinct triggers.</p>
<ul>
<li><p><code>opened</code> fires when the PR is first created.</p>
</li>
<li><p><code>synchronize</code> fires on every subsequent push to the branch, which is what keeps the environment current.</p>
</li>
<li><p><code>reopened</code> handles a PR that was closed and then reopened. Without it, that PR would have a torn-down environment and nothing to recreate it.</p>
</li>
</ul>
<p><code>concurrency</code>: this one saves real debugging time. Push three commits in quick succession, and you get three workflow runs targeting the same namespace. They race. The one that finishes last wins, and that's not necessarily the newest code. A fast early build can overtake a slow later one, leaving your preview environment silently running stale code while the PR shows green. Grouping by PR number with <code>cancel-in-progress: true</code> means only the newest run survives.</p>
<p><code>permissions: id-token: write</code>: without this line, nothing else works. It's what allows the workflow to request an OIDC token from GitHub. Leave it out and <code>google-github-actions/auth</code> fails with an error about being unable to get an ID token. This is the single most common WIF setup mistake.</p>
<p><code>$GITHUB_ENV</code>: writing <code>KEY=value</code> to this file makes the variable available in all <em>subsequent</em> steps. Not the current one, as a variable you write in a step can't be read later in that same step. It's why the setup happens in its own step at the top.</p>
<p><code>github.event.pull_request.head.sha</code>: note this rather than <code>github.sha</code>. On <code>pull_request</code> events, GitHub checks out a <em>merge commit</em>. This is a temporary commit combining your branch with the base branch. <code>github.sha</code> refers to that merge commit, which doesn't exist in your repository's history and will confuse anyone who tries to look it up. <code>head.sha</code> is the actual last commit on the PR branch, which is what a developer expects to see.</p>
<p><code>--dry-run=client -o yaml | kubectl apply -f -</code> is a small trick worth stealing. <code>kubectl create namespace</code> fails if the namespace already exists, which would break every push after the first. This pipeline generates the namespace YAML without contacting the cluster, then pipes it to<code>apply</code>, which creates it if it's missing and does nothing if it isn't. Idempotent by construction.</p>
<p><strong>The heredoc overlay</strong>: instead of committing an overlay per PR (impossible, as you don't know PR numbers in advance), the workflow writes one at runtime and throws it away when the runner is destroyed. It's ephemeral configuration for an ephemeral environment.</p>
<p><code>kubectl rollout status</code> is what makes the workflow honest. Without it, the job succeeds the moment <code>kubectl apply</code> is accepted by the API server, which happens before a single pod has started. A crash-looping image would still show a green check. <code>rollout status</code> blocks until pods are actually ready, or fails after 180 seconds. Green means working.</p>
<h3 id="heading-a-note-on-forked-pull-requests">A Note on Forked Pull Requests</h3>
<p>Something is worth knowing before you deploy this anywhere public: pull requests from forks do <strong>not</strong> receive <code>id-token: write</code> permission, by design. GitHub blocks it so a stranger can't open a PR that steals your cloud credentials.</p>
<p>For a private repository or a personal project, this doesn't affect you. For a public repository accepting outside contributions, you'd need a <code>workflow_run</code>-based pattern where a privileged workflow runs from the base repository after the untrusted build completes. That's beyond this handbook's scope, but if you're deploying this on an open-source project, don't skip it.</p>
<h2 id="heading-step-10-build-the-teardown-workflow">Step 10: Build the Teardown Workflow</h2>
<p>Spin-up is only half the value. An environment that never disappears is just a slow leak in your cloud bill.</p>
<p>Create <code>.github/workflows/preview-teardown.yml</code>:</p>
<pre><code class="language-yaml">name: Tear Down PR Preview

on:
  pull_request:
    types: [closed]

permissions:
  contents: read
  id-token: write

env:
  PROJECT_ID: YOUR_PROJECT_ID
  CLUSTER_NAME: pr-preview-cluster
  CLUSTER_ZONE: europe-west2-a

jobs:
  teardown:
    name: Delete preview namespace
    runs-on: ubuntu-latest

    steps:
      - name: Set namespace
        run: echo "NAMESPACE=pr-${{ github.event.pull_request.number }}" &gt;&gt; $GITHUB_ENV

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
          service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}

      - name: Get GKE credentials
        uses: google-github-actions/get-gke-credentials@v2
        with:
          cluster_name: ${{ env.CLUSTER_NAME }}
          location: ${{ env.CLUSTER_ZONE }}

      - name: Delete namespace
        run: |
          echo "Deleting namespace ${NAMESPACE}"
          kubectl delete namespace "${NAMESPACE}" \
            --ignore-not-found=true \
            --wait=false

      - name: Confirm
        run: |
          echo "Preview environment for PR #${{ github.event.pull_request.number }} torn down."
</code></pre>
<p>A few details here matter more than they might seem.</p>
<p>First, <code>types: [closed]</code> <strong>covers merges too.</strong> GitHub doesn't have a separate <code>merged</code> event type. Merging a pull request fires <code>closed</code> with the payload field <code>merged: true</code>. So this single trigger handles both "merged successfully" and "abandoned and closed." Both should tear down the environment, so we don't need to distinguish between them.</p>
<p>Next, <code>--ignore-not-found=true</code>: if the namespace doesn't exist (perhaps the deploy failed, or someone deleted it manually), <code>kubectl delete</code> would normally exit with an error and mark the workflow red. That's a false alarm: the desired end state is "namespace doesn't exist," and it doesn't. This flag makes the step succeed when there's nothing to do.</p>
<p>Finally, <code>--wait=false</code>: namespace deletion is asynchronous. Kubernetes marks it<code>Terminating</code>, then works through deleting every resource inside it. Waiting for that to complete could hold your CI runner for a minute or more, for no benefit. Fire the delete and let the cluster finish on its own time.</p>
<h2 id="heading-step-11-open-a-pull-request-and-watch-it-work">Step 11: Open a Pull Request and Watch It Work</h2>
<p>Commit everything and push:</p>
<pre><code class="language-bash">git add .
git commit -m "Add PR preview environment infrastructure"
git push -u origin main
</code></pre>
<p>Now create a branch and make a visible change:</p>
<pre><code class="language-bash">git checkout -b test-preview
</code></pre>
<p>Edit <code>app/main.go</code>. Change the first line of the handler output:</p>
<pre><code class="language-go">fmt.Fprintf(w, "Preview environment is live — testing the pipeline\n\n")
</code></pre>
<p>Commit and push:</p>
<pre><code class="language-bash">git add .
git commit -m "Test preview environment"
git push -u origin test-preview
</code></pre>
<p>Open a pull request on GitHub, then go to the <strong>Actions</strong> tab. You should see "Deploy PR Preview" running.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/4da5dcda-506a-4a9d-8413-5c5cd32af0c1.png" alt="GitHub Actions summary for a pull request showing the Deploy PR Preview workflow completed successfully" style="display: block;" width="600" height="400" loading="lazy">

<p>Watch the steps in order: authentication succeeds without a key, the image builds and pushes, the namespace is created, Kustomize renders the overlay (you'll see the fully resolved manifest printed), the deployment applies, and <code>rollout status</code> waits until pods are ready.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/3f0ae123-82fb-447e-9a5a-db1e87589340.png" alt="Expanded GitHub Actions log showing the deploy, wait for rollout, and show deployment details steps completing successfully" style="display: block;" width="600" height="400" loading="lazy">

<p>Once it's green, verify from your terminal:</p>
<pre><code class="language-shell">kubectl get namespaces | grep pr-
</code></pre>
<p>You should see your <code>pr-1</code> (or whatever number your PR got).</p>
<pre><code class="language-shell">kubectl get pods --namespace=pr-1
</code></pre>
<p>Now reach the application:</p>
<pre><code class="language-shell">kubectl port-forward --namespace=pr-1 service/preview-app 8080:80
</code></pre>
<p>In a second terminal:</p>
<pre><code class="language-shell">curl http://localhost:8080
</code></pre>
<p>You should see:</p>
<pre><code class="language-shell">Preview environment is live — testing the pipeline

PR number  : 1
Git SHA    : a1b2c3d
Namespace  : pr-1
Pod name   : preview-app-7d9f8b6c4-xk2mp
</code></pre>
<p>The application is reporting the PR number and commit SHA that were baked into its binary at build time, and the namespace it discovered through the Downward API. Every one of those values was resolved by a different part of the pipeline, and they all agree. That's your proof the whole chain works.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/17b0712a-0fa3-47be-8078-67663239acfe.png" alt="Terminal output from curl showing the preview environment reporting PR number 1, its commit SHA, namespace pr-1, and the pod name" style="display: block;" width="600" height="400" loading="lazy">

<p>Now push another commit to the same branch and watch the environment update in place.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/2ca76645-9fe0-491d-8dae-05789a6b5498.png" alt="GitHub Actions log showing the Deploy PR Preview workflow running again after a second commit was pushed to the same branch" style="display: block;" width="600" height="400" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/426cc240-5ee7-487b-9fe4-dc955b06ac31.png" alt="Terminal showing kubectl port-forward running against namespace pr-1 alongside the curl command used to reach it" style="display: block;" width="600" height="400" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/a66c098b-b972-4282-8f84-6f4ab49fc34b.png" alt="Terminal output from curl showing the updated greeting text and new commit SHA after the second push, confirming the environment redeployed in place" style="display: block;" width="600" height="400" loading="lazy">

<p>Then close the pull request and watch the teardown workflow fire.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/bbcacedc-2ade-4945-b849-fd6be8931819.png" alt="GitHub Actions teardown workflow triggered and in progress" style="display: block;" width="600" height="400" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/4b5fb004-ebb6-424c-a535-caa9f8037265.png" alt="GitHub Actions teardown workflow completing" style="display: block;" width="600" height="400" loading="lazy">

<pre><code class="language-bash">kubectl get namespace pr-1
</code></pre>
<p><code>Terminating</code>, then gone.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/8d373dd3-f627-400c-aad2-9a845b549ce5.png" alt="Terminal output showing kubectl get namespace returning &quot;namespaces pr-1 not found&quot;" style="display: block;" width="600" height="400" loading="lazy">

<h3 id="heading-what-you-have-and-whats-still-missing">What You Have, and What's Still Missing</h3>
<p>Stop and take stock, because the hard infrastructure work is done. A pull request now produces a real, isolated environment with no manual steps, and closing it removes every trace. That's the whole lifecycle, automated.</p>
<p>But look again at how you reached it:</p>
<pre><code class="language-plaintext">kubectl port-forward --namespace=pr-1 service/preview-app 8080:80
</code></pre>
<p>That requires <code>kubectl</code> installed, credentials for the cluster, permission to use them, and knowledge of which namespace maps to which PR. This is fine for the person who built the pipeline, but it's useless for the designer checking a spacing change, the product manager reviewing a copy edit, or the QA engineer clicking through a flow before sign-off.</p>
<p>The whole point of a preview environment is that <em><strong>anyone</strong></em> can look at the change. An environment only engineers can reach isn't really a preview environment. It's a debugging tool.</p>
<p>The rest of this tutorial closes that gap, and then makes the result safe to leave running. First, we'll set up a public HTTPS URL for every environment. Then we'll build the guardrails and go through the cleanup that stop it becoming a quiet line item on your cloud bill.</p>
<h2 id="heading-step-12-reserve-a-static-ip-address">Step 12: Reserve a Static IP Address</h2>
<p>Before installing anything, you'll need to reserve an external IP address.</p>
<p>This matters because when you install the Ingress controller, it creates a Kubernetes Service of type <code>LoadBalancer</code>, and Google Cloud provisions a real load balancer with a public IP. By default, that IP is <strong>ephemeral</strong>. If the Service is deleted and recreated, you get a different one.</p>
<p>Your wildcard DNS record points at that IP. If it changes, every preview environment breaks at once, and you won't find out until someone tells you the link is dead. Reserving the IP first means it belongs to your project, not to the load balancer, and it survives anything you do to the cluster.</p>
<p>Add this to <code>terraform/main.tf</code>:</p>
<pre><code class="language-hcl"># -----------------------------------------------------------
# Static external IP for the Ingress controller
# -----------------------------------------------------------
resource "google_compute_address" "ingress" {
  name         = "pr-preview-ingress-ip"
  region       = var.region
  address_type = "EXTERNAL"

  depends_on = [google_project_service.apis]
}
</code></pre>
<p>And add the output to <code>terraform/outputs.tf</code>:</p>
<pre><code class="language-hcl">output "ingress_ip" {
  description = "Point your wildcard DNS record at this address"
  value       = google_compute_address.ingress.address
}
</code></pre>
<p>You'll also want <code>compute.googleapis.com</code> in the APIs list if it isn't there already. Update the <code>for_each</code> set in your <code>google_project_service</code> resource:</p>
<pre><code class="language-hcl">  for_each = toset([
    "container.googleapis.com",
    "artifactregistry.googleapis.com",
    "iamcredentials.googleapis.com",
    "sts.googleapis.com",
    "compute.googleapis.com",
  ])
</code></pre>
<p>Apply it:</p>
<pre><code class="language-bash">cd terraform
terraform apply \
  -var="project_id=YOUR_PROJECT_ID" \
  -var="github_repo=YOUR_USERNAME/pr-preview-envs"
</code></pre>
<p>Note the IP that comes back:</p>
<pre><code class="language-bash">terraform output ingress_ip
</code></pre>
<p>Write it down. You'll use it twice in the next two steps.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/a9b0586d-3b1e-4fe2-9eb4-53087b70a45a.png" alt="terraform output ingress_ip showing the reserved address" style="display: block;" width="600" height="400" loading="lazy">

<p><strong>Important:</strong> this must be a <strong>regional</strong> address in the same region as your cluster, which is what <code>region = var.region</code> gives you. Google Cloud also offers global addresses, used by global HTTP(S) load balancers.</p>
<p>The Service type <code>LoadBalancer</code> that ingress-nginx creates is a regional load balancer, and it can't bind a global address. Mixing these up produces a Service stuck in <code>Pending</code> forever with an error that doesn't clearly explain why.</p>
<h2 id="heading-step-13-install-the-ingress-controller">Step 13: Install the Ingress Controller</h2>
<p>An <strong>Ingress</strong> is a Kubernetes resource that describes HTTP routing rules: "requests for this hostname go to that Service." But the Ingress resource is only a description. Something has to actually read those rules and route traffic, and that something is an <strong>Ingress controller</strong>.</p>
<p>We'll use <strong>ingress-nginx</strong>, which is the community-maintained controller built on NGINX. It runs as pods in your cluster, watches the Kubernetes API for Ingress resources, and rewrites its own NGINX configuration whenever one changes.</p>
<p>The architectural point that makes this work for preview environments is that <strong>one controller serves every namespace</strong>. Each preview environment creates its own Ingress resource. The controller picks it up, adds a routing rule for that hostname, and sends traffic to the right Service. The controller doesn't care how many environments exist.</p>
<p>Contrast this with what we'd otherwise do. If each preview environment used a <code>LoadBalancer</code> Service, every PR would provision its own Google Cloud load balancer, each with an hourly charge and its own IP address that DNS would need to know about. Ten open PRs would mean ten load balancers. With a shared Ingress controller, ten open PRs mean one.</p>
<p>Install it with Helm:</p>
<pre><code class="language-bash">helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update

helm install ingress-nginx ingress-nginx/ingress-nginx \
  --namespace ingress-nginx \
  --create-namespace \
  --set controller.service.loadBalancerIP="YOUR_STATIC_IP" \
  --set controller.service.externalTrafficPolicy=Local
</code></pre>
<p>Replace <code>YOUR_STATIC_IP</code> with the address from Step 12.</p>
<p>Let's break down the flags before we go on:</p>
<p><code>--namespace ingress-nginx --create-namespace</code>: the controller gets its own namespace, separate from any preview environment. It's shared infrastructure, and it should never be caught up in a preview environment's teardown.</p>
<p><code>controller.service.loadBalancerIP</code>: tells GKE to bind the load balancer to the specific address you reserved, rather than allocating a new one. This is the line that makes your DNS record stable.</p>
<p><code>controller.service.externalTrafficPolicy=Local</code>: preserves the client's real source IP. Without it, Kubernetes may route a request through an intermediate node, and by the time it reaches NGINX the source IP has been rewritten to a node's internal address. Your access logs would show cluster IPs instead of real visitors.</p>
<p>The tradeoff is that traffic only lands on nodes actually running a controller pod, which is fine here.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/7813b917-231e-44fc-9d5b-9501c1745145.png" alt="Terminal output from helm install showing the ingress-nginx chart deployed successfully into the ingress-nginx namespace" style="display: block;" width="600" height="400" loading="lazy">

<p>Watch for the external IP to be assigned:</p>
<pre><code class="language-bash">kubectl get service ingress-nginx-controller \
  --namespace ingress-nginx \
  --watch
</code></pre>
<p>It shows <code>&lt;pending&gt;</code> for a minute or two while Google provisions the load balancer, then displays your static IP from Step 12. Press Ctrl+C once it appears.</p>
<p>Verify that the controller is healthy:</p>
<pre><code class="language-bash">kubectl get pods --namespace ingress-nginx
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/f38b8e9c-d7e6-4c18-b5ac-927aef24118f.png" alt="Output of kubectl get service and kubectl get pods showing the ingress-nginx controller running with the reserved static external IP assigned" style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-step-14-point-wildcard-dns-at-the-load-balancer">Step 14: Point Wildcard DNS at the Load Balancer</h2>
<p>This step takes about two minutes and is the piece that makes the whole pattern scale.</p>
<p>Go to wherever your domain's DNS is managed and create a single <strong>A record</strong>:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Type</td>
<td><code>A</code></td>
</tr>
<tr>
<td>Name / Host</td>
<td><code>*.preview</code></td>
</tr>
<tr>
<td>Value</td>
<td>Your static IP from Step 1</td>
</tr>
<tr>
<td>TTL</td>
<td>300 (or the lowest your provider allows)</td>
</tr>
</tbody></table>
<p>That's it. That's the entire DNS configuration for the project.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/461ad5af-b55e-4ec4-912a-0fac144a4db5.png" alt="DNS provider interface showing a wildcard A record for *.preview pointing to the static IP address of the ingress load balancer" style="display: block;" width="600" height="400" loading="lazy">

<p>Here's why one record is enough. A wildcard DNS record matches <strong>any</strong> label in that position. <code>*.preview.yourdomain.com</code> resolves <code>pr-1.preview.yourdomain.com</code>, <code>pr-42.preview.yourdomain.com</code>, and <code>pr-99999.preview.yourdomain.com</code>, all to the same IP, all without anyone creating a record for them.</p>
<p>This is worth pausing on, because it's the design decision that keeps the system simple. The obvious approach would be to have the CI pipeline create a DNS record when a PR opens and delete it when the PR closes. That means DNS API credentials in your pipeline, another thing to fail, another thing to leak, and another source of orphaned resources.</p>
<p>The wildcard approach eliminates that entire category of work: DNS is configured once, manually, and never touched again.</p>
<p>Routing to the right environment doesn't happen in DNS. Every hostname resolves to the same load balancer, and the Ingress controller reads the <code>Host</code> header of each incoming request to decide which namespace's Service should handle it.</p>
<p>Verify the record has propagated:</p>
<pre><code class="language-bash">dig +short anything-at-all.preview.yourdomain.com
</code></pre>
<p>You should get your static IP back. Note that the subdomain doesn't need to exist yet. That's the whole point of a wildcard.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/a11681bf-5899-47b5-9731-f45096915f02.png" alt="Terminal output from dig returning the ingress static IP for a subdomain that was never explicitly created, confirming the wildcard record resolves" style="display: block;" width="600" height="400" loading="lazy">

<p>If nothing comes back, wait a few minutes and try again. DNS propagation is usually fast with a low TTL, but it isn't instant.</p>
<h2 id="heading-step-15-install-cert-manager-and-create-a-clusterissuer">Step 15: Install cert-manager and Create a ClusterIssuer</h2>
<p>Now for HTTPS.</p>
<p><strong>cert-manager</strong> is a Kubernetes controller that automates the full certificate lifecycle. It watches for Ingress resources annotated with an issuer, requests a certificate from a certificate authority, proves you control the domain, stores the certificate in a Kubernetes Secret, and renews it before it expires. Ther eare no cron jobs, manual renewal, or expired-certificate incident at 3 am.</p>
<p>Install it:</p>
<pre><code class="language-bash">helm repo add jetstack https://charts.jetstack.io
helm repo update

helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --version v1.21.2 \
  --set crds.enabled=true
</code></pre>
<p><code>--set crds.enabled=true</code> Installs cert-manager's Custom Resource Definitions: <code>Certificate</code>, <code>Issuer</code>, <code>ClusterIssuer</code>, <code>CertificateRequest</code> and others. These extend the Kubernetes API with new resource types. Without them, cert-manager has nothing to watch for. (If you're following an older tutorial that uses <code>installCRDs=true</code>, that flag was renamed in v1.15.)</p>
<p>Check it's running:</p>
<pre><code class="language-bash">kubectl get pods --namespace cert-manager
</code></pre>
<p>You should see three pods: the controller, the webhook, and cainjector.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/dad07949-4123-4854-861e-5d96e19a4591.png" alt="Output of kubectl get pods showing the cert-manager controller, webhook, and cainjector pods running" style="display: block;" width="600" height="400" loading="lazy">

<h3 id="heading-create-the-clusterissuer">Create the ClusterIssuer</h3>
<p>An <strong>Issuer</strong> tells cert-manager where to get certificates from. A <strong>ClusterIssuer</strong> is the same thing with cluster-wide scope, which is what we need. A namespaced Issuer would have to be recreated in every single preview namespace.</p>
<p>Create <code>k8s/cluster/letsencrypt-staging.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    # Let's Encrypt's staging environment. Certificates from this
    # server are NOT trusted by browsers, but the rate limits are
    # far more generous. Use this while debugging.
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: YOUR_EMAIL@example.com
    privateKeySecretRef:
      name: letsencrypt-staging-account-key
    solvers:
      - http01:
          ingress:
            ingressClassName: nginx
</code></pre>
<p>And <code>k8s/cluster/letsencrypt-prod.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: YOUR_EMAIL@example.com
    privateKeySecretRef:
      name: letsencrypt-prod-account-key
    solvers:
      - http01:
          ingress:
            ingressClassName: nginx
</code></pre>
<p>Apply both:</p>
<pre><code class="language-bash">kubectl apply -f k8s/cluster/letsencrypt-staging.yaml
kubectl apply -f k8s/cluster/letsencrypt-prod.yaml
</code></pre>
<p>Confirm they're ready:</p>
<pre><code class="language-bash">kubectl get clusterissuer
</code></pre>
<p>Both should show <code>READY: True</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/e76271ef-0b0c-471f-b826-d5f476bc33f2.png" alt="Output of kubectl get clusterissuer showing both letsencrypt-staging and letsencrypt-prod with READY status True" style="display: block;" width="600" height="400" loading="lazy">

<h3 id="heading-why-two-issuers">Why Two Issuers?</h3>
<p>This isn't redundancy. It's how you avoid a frustrating afternoon.</p>
<p>Let's Encrypt enforces strict rate limits on its production environment. The one that matters here is <strong>50 certificates per registered domain per week</strong>. There's also a duplicate-certificate limit of 5 per week for the identical set of hostnames.</p>
<p>While you're setting this up, you'll likely misconfigure something. The Ingress annotation will be wrong, or the HTTP-01 challenge won't route correctly, or DNS won't have propagated. Each failed attempt burns against your quota, and once you hit the limit you're locked out for a week with no appeal.</p>
<p>The staging issuer has far looser limits. Its certificates are signed by an untrusted root, so browsers show a warning, but that warning <em>is itself proof the pipeline works</em>. If you see a certificate warning naming "Let's Encrypt Staging," everything functioned correctly, and the only remaining step is switching issuers.</p>
<p>Use <code>letsencrypt-staging</code> until the flow works end to end, then switch to <code>letsencrypt-prod</code>.</p>
<h3 id="heading-how-the-http-01-challenge-works">How the HTTP-01 Challenge Works</h3>
<p>This is worth understanding, because it's what breaks when things go wrong.</p>
<p>When cert-manager requests a certificate for <code>pr-42.preview.yourdomain.com</code>, Let's Encrypt needs proof you control that hostname. With the HTTP-01 solver, the sequence is:</p>
<ol>
<li><p>Let's Encrypt gives cert-manager a random token.</p>
</li>
<li><p>cert-manager creates a temporary pod, Service, and Ingress rule that serves that token at <code>http://pr-42.preview.yourdomain.com/.well-known/acme-challenge/&lt;token&gt;</code>.</p>
</li>
<li><p>Let's Encrypt makes a real HTTP request to that URL from the public internet.</p>
</li>
<li><p>If the token matches, domain control is proven and the certificate is issued.</p>
</li>
<li><p>cert-manager deletes the temporary resources and stores the certificate in a Secret.</p>
</li>
</ol>
<p>Three things must be true for this to work, and they're the three things to check when it doesn't: DNS must resolve the hostname to your load balancer, port 80 must be reachable from the internet (don't redirect everything to HTTPS at the load balancer level. The challenge is plain HTTP), and the Ingress controller must be routing correctly.</p>
<h3 id="heading-a-note-on-scale">A Note on Scale</h3>
<p>HTTP-01 issues one certificate per hostname, and every preview environment has a unique hostname. That's one certificate per pull request.</p>
<p>Against Let's Encrypt's 50-per-week limit, that's comfortable for a solo project or a small team. An active team opening more than 50 PRs a week would hit the ceiling.</p>
<p>The production answer is to issue a <strong>single wildcard certificate</strong> for <code>*.preview.yourdomain.com</code> and share it across every namespace. Wildcards can't be issued over HTTP-01. They require the <strong>DNS-01</strong> challenge, where cert-manager proves control by creating a TXT record instead of serving a file.</p>
<p>With Cloud DNS, cert-manager can do this automatically using a Google service account. You'd then replicate the resulting Secret into each preview namespace with a tool like <a href="https://github.com/emberstack/kubernetes-reflector">reflector</a>.</p>
<p>That's more moving parts than this handbook needs, but it's the right answer once your PR volume justifies it, and it's worth knowing that limit exists before you hit it.</p>
<h2 id="heading-step-16-add-an-ingress-to-the-base-manifests">Step 16: Add an Ingress to the Base Manifests</h2>
<p>Now we'll give the application a route.</p>
<p>Create <code>k8s/base/ingress.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: preview-app
  annotations:
    # Tells cert-manager to request a certificate for the hosts
    # listed under spec.tls, using this ClusterIssuer.
    cert-manager.io/cluster-issuer: letsencrypt-staging
    # Redirect HTTP to HTTPS once the certificate exists.
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - preview-host-placeholder
      secretName: preview-app-tls
  rules:
    - host: preview-host-placeholder
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: preview-app
                port:
                  number: 80
</code></pre>
<p>Add it to <code>k8s/base/kustomization.yaml</code>:</p>
<p>You created this file back in Step 8 with two resources. Add the Ingress to it:</p>
<pre><code class="language-yaml">apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - deployment.yaml
  - service.yaml
  - ingress.yaml
</code></pre>
<p>Here are the pieces that matter:</p>
<p><code>cert-manager.io/cluster-issuer</code>: this single annotation is what triggers the entire certificate workflow. cert-manager watches for it, reads the hostnames from <code>spec.tls</code>, and does everything else. Note it's set to <code>letsencrypt-staging</code> for now. We'll switch to production once the flow is proven.</p>
<p><code>ingressClassName: nginx</code>: identifies which controller should handle this Ingress. A cluster can run several controllers simultaneously, and this field prevents them from fighting over the same resource. Older tutorials use the <code>kubernetes.io/ingress.class</code> annotation, which is deprecated.</p>
<p><code>secretName: preview-app-tls</code>: where cert-manager stores the issued certificate. It's created automatically in the same namespace as the Ingress, so each preview environment has its own.</p>
<p><code>preview-host-placeholder</code>: deliberately not a valid hostname. It appears twice: once in <code>spec.tls.hosts</code> and once in <code>spec.rules.host</code>, and both get replaced per PR in the next step. Using something that obviously isn't a real domain means that if the patch ever fails, you get an immediate visible error rather than a subtle misroute.</p>
<p><code>pathType: Prefix</code> <strong>with</strong> <code>path: /</code>: matches every path on the host. Preview environments serve the whole application, not a subpath.</p>
<h2 id="heading-step-17-patch-the-hostname-per-pull-request">Step 17: Patch the Hostname Per Pull Request</h2>
<p>Each environment needs its own hostname substituted into two places in the Ingress. Kustomize handles this with a <strong>JSON 6902 patch</strong>. A precise operation that targets an exact path within a resource.</p>
<p>In Step 9, the workflow generated an overlay that set the namespace and image tag. We extend that generated overlay with a patch block. Here's what the complete generated <code>kustomization.yaml</code> looks like for PR 42:</p>
<pre><code class="language-yaml">apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

namespace: pr-42

resources:
  - ../../base

images:
  - name: preview-app
    newName: europe-west2-docker.pkg.dev/your-project/pr-preview/preview-app
    newTag: pr-42-a1b2c3d

patches:
  - target:
      kind: Ingress
      name: preview-app
    patch: |-
      - op: replace
        path: /spec/rules/0/host
        value: pr-42.preview.yourdomain.com
      - op: replace
        path: /spec/tls/0/hosts/0
        value: pr-42.preview.yourdomain.com
</code></pre>
<p>The patch syntax reads literally: in the resource matching <code>kind: Ingress, name: preview-app</code>, replace the value at <code>/spec/rules/0/host</code>, and replace the value at <code>/spec/tls/0/hosts/0</code>.</p>
<p>The <code>0</code> indices are array positions. <code>/spec/rules/0</code> is the first (and only) rule. <code>/spec/tls/0/hosts/0</code> is the first hostname in the first TLS block. If you later add a second hostname per environment, you'd add another operation targeting index <code>1</code>.</p>
<p>Why a JSON 6902 patch rather than a strategic merge patch? Because strategic merge on an array of objects is ambiguous. Kubernetes has to guess whether you mean "replace this list" or "merge into an existing entry," and the answer depends on per-field merge keys. For replacing a single value at a known position, an explicit <code>op: replace</code> says exactly what you mean and fails loudly if the path doesn't exist.</p>
<p>The workflow generates this file at runtime, so you never commit a per-PR overlay. We'll write that step in Step 19.</p>
<h2 id="heading-step-18-add-guardrails-with-resourcequota-and-limitrange">Step 18: Add Guardrails with ResourceQuota and LimitRange</h2>
<p>Before we wire up the workflow, let's stop a preview environment from being able to take down the cluster.</p>
<p>Right now nothing prevents a pull request from deploying a manifest requesting 32 CPUs, or scaling to 200 replicas, or creating a dozen <code>LoadBalancer</code> Services. Someone doesn't need bad intent for this to happen. A bad merge, a copied manifest, or a typo in a replica count does it just as effectively.</p>
<p>Two Kubernetes resources solve this, and they work together.</p>
<p>Create <code>k8s/cluster/namespace-guardrails.yaml</code> as a template:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: ResourceQuota
metadata:
  name: preview-quota
spec:
  hard:
    requests.cpu: "1"
    requests.memory: 1Gi
    limits.cpu: "2"
    limits.memory: 2Gi
    pods: "10"
    services: "5"
    services.loadbalancers: "0"
    persistentvolumeclaims: "2"
---
apiVersion: v1
kind: LimitRange
metadata:
  name: preview-limits
spec:
  limits:
    - type: Container
      # Applied to containers that don't specify their own
      defaultRequest:
        cpu: 50m
        memory: 64Mi
      default:
        cpu: 200m
        memory: 128Mi
      # Hard ceiling — a container asking for more is rejected
      max:
        cpu: "1"
        memory: 512Mi
</code></pre>
<h3 id="heading-what-each-one-does">What Each One Does</h3>
<p>A <strong>ResourceQuota</strong> caps the total consumption of an entire namespace. Add up every pod's requests and limits. The sum can't exceed these numbers. A request that would push the namespace over its quota is rejected by the API server at admission time, before anything is scheduled.</p>
<p>The <code>services.loadbalancers: "0"</code> line is worth highlighting. It makes it structurally impossible for a preview environment to provision a Google Cloud load balancer, even if someone commits a manifest with <code>type: LoadBalancer</code>. That's a direct financial guardrail: the most expensive mistake this system could make is now unrepresentable.</p>
<p>A <strong>LimitRange</strong> operates on individual containers rather than the namespace total. It sets defaults for containers that don't specify resources, and a hard maximum that no single container may exceed.</p>
<h3 id="heading-why-they-must-be-deployed-together">Why They Must Be Deployed Together</h3>
<p>Once a ResourceQuota that constrains <code>requests.cpu</code> exists in a namespace, <strong>every container in that namespace must declare resource requests</strong>. A pod without them is rejected outright, because Kubernetes can't verify it fits within the quota if it doesn't know what it's asking for.</p>
<p>That means adding a ResourceQuota on its own will break any manifest that omits resource specs, and most manifests found in the wild do omit them.</p>
<p>The LimitRange fixes this by supplying defaults. A container with no resource spec gets the <code>defaultRequest</code> and <code>default</code> values injected automatically at admission time, which satisfies the quota's requirement.</p>
<p>Deploy one without the other, and you get confusing failures. Deploy both and the system is forgiving of incomplete manifests while still being strictly bounded.</p>
<h3 id="heading-sizing-the-numbers">Sizing the Numbers</h3>
<p>The values above suit a small application like ours. To adjust them, work backwards from your node capacity.</p>
<p>Two <code>e2-standard-2</code> nodes give you 4 vCPU and 16GB total, minus roughly 15% that GKE reserves for system components. Call it 3.4 usable vCPU. With <code>requests.cpu: "1"</code> per namespace, three preview environments could theoretically fill the cluster.</p>
<p>Our application requests only 50m CPU, so in practice you'll fit far more than three. But the quota is a ceiling on what a namespace <em>may</em> request, and you should set it well below "one PR can consume everything."</p>
<p>If you expect ten concurrent environments, size the per-namespace quota at roughly a tenth of usable capacity, or add nodes.</p>
<h2 id="heading-step-19-update-the-deploy-workflow">Step 19: Update the Deploy Workflow</h2>
<p>Here's the complete <code>.github/workflows/preview-deploy.yml</code>, with everything added in this section marked <code># NEW</code>. Replace your Step 9 version with this one.</p>
<pre><code class="language-yaml">name: Deploy PR Preview

on:
  pull_request:
    types: [opened, synchronize, reopened]

concurrency:
  group: preview-${{ github.event.pull_request.number }}
  cancel-in-progress: true

permissions:
  contents: read
  id-token: write
  pull-requests: write

env:
  PROJECT_ID: YOUR_PROJECT_ID
  REGION: europe-west2
  CLUSTER_NAME: pr-preview-cluster
  CLUSTER_ZONE: europe-west2-a
  REPOSITORY: pr-preview
  IMAGE_NAME: preview-app
  # NEW: the base domain for all preview environments
  PREVIEW_DOMAIN: preview.yourdomain.com
  # NEW: switch to letsencrypt-prod once the flow is proven
  CLUSTER_ISSUER: letsencrypt-staging

jobs:
  deploy:
    name: Build and deploy preview
    runs-on: ubuntu-latest

    steps:
      - name: Check out code
        uses: actions/checkout@v4

      - name: Set environment variables
        run: |
          PR_NUMBER=${{ github.event.pull_request.number }}
          SHORT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7)

          echo "PR_NUMBER=${PR_NUMBER}"    &gt;&gt; $GITHUB_ENV
          echo "SHORT_SHA=${SHORT_SHA}"    &gt;&gt; $GITHUB_ENV
          echo "NAMESPACE=pr-${PR_NUMBER}" &gt;&gt; $GITHUB_ENV
          echo "IMAGE_TAG=pr-${PR_NUMBER}-${SHORT_SHA}" &gt;&gt; $GITHUB_ENV
          echo "IMAGE_BASE=${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}" &gt;&gt; $GITHUB_ENV
          # NEW: the hostname this environment will be reachable at
          echo "PREVIEW_HOST=pr-${PR_NUMBER}.${{ env.PREVIEW_DOMAIN }}" &gt;&gt; $GITHUB_ENV

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
          service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}

      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v2

      - name: Configure Docker for Artifact Registry
        run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet

      - name: Build and push image
        run: |
          docker build \
            --build-arg PR_NUMBER="${PR_NUMBER}" \
            --build-arg GIT_SHA="${SHORT_SHA}" \
            -t "${IMAGE_BASE}:${IMAGE_TAG}" \
            ./app

          docker push "${IMAGE_BASE}:${IMAGE_TAG}"

      - name: Get GKE credentials
        uses: google-github-actions/get-gke-credentials@v2
        with:
          cluster_name: ${{ env.CLUSTER_NAME }}
          location: ${{ env.CLUSTER_ZONE }}

      - name: Create namespace
        run: |
          kubectl create namespace "${NAMESPACE}" \
            --dry-run=client -o yaml | kubectl apply -f -

          # NEW: label it so the reaper knows this namespace is ours
          kubectl label namespace "${NAMESPACE}" \
            app.kubernetes.io/managed-by=pr-preview \
            preview.pr="${PR_NUMBER}" \
            --overwrite

      # NEW: apply the quota and limits before anything deploys
      - name: Apply namespace guardrails
        run: |
          kubectl apply -f k8s/cluster/namespace-guardrails.yaml \
            --namespace="${NAMESPACE}"

      - name: Generate Kustomize overlay
        run: |
          mkdir -p k8s/overlays/"${NAMESPACE}"
          cat &gt; k8s/overlays/"${NAMESPACE}"/kustomization.yaml &lt;&lt;EOF
          apiVersion: kustomize.config.k8s.io/v1beta1
          kind: Kustomization

          namespace: ${NAMESPACE}

          resources:
            - ../../base

          images:
            - name: preview-app
              newName: ${IMAGE_BASE}
              newTag: ${IMAGE_TAG}

          patches:
            - target:
                kind: Ingress
                name: preview-app
              patch: |-
                - op: replace
                  path: /spec/rules/0/host
                  value: ${PREVIEW_HOST}
                - op: replace
                  path: /spec/tls/0/hosts/0
                  value: ${PREVIEW_HOST}
                - op: replace
                  path: /metadata/annotations/cert-manager.io~1cluster-issuer
                  value: ${CLUSTER_ISSUER}
          EOF

          echo "--- Rendered manifest ---"
          kubectl kustomize k8s/overlays/"${NAMESPACE}"

      - name: Deploy
        run: kubectl apply -k k8s/overlays/"${NAMESPACE}"

      - name: Wait for rollout
        run: |
          kubectl rollout status deployment/preview-app \
            --namespace="${NAMESPACE}" \
            --timeout=180s

      # NEW: wait for the TLS certificate to be issued
      - name: Wait for certificate
        continue-on-error: true
        run: |
          echo "Waiting for TLS certificate for ${PREVIEW_HOST}..."
          kubectl wait certificate/preview-app-tls \
            --namespace="${NAMESPACE}" \
            --for=condition=Ready \
            --timeout=180s

      - name: Show deployment details
        run: |
          echo "Preview URL: https://${PREVIEW_HOST}"
          kubectl get pods,svc,ingress,certificate --namespace="${NAMESPACE}"
</code></pre>
<p>There are a few things to understand about the new steps.</p>
<p><strong>The</strong> <code>~1</code> <strong>in the annotation patch path:</strong> this looks like a typo and isn't:</p>
<pre><code class="language-plaintext">path: /metadata/annotations/cert-manager.io~1cluster-issuer
</code></pre>
<p>JSON Pointer paths use <code>/</code> as a separator, so a literal <code>/</code> inside a key has to be escaped. The escape sequence is <code>~1</code>. (And <code>~</code> itself escapes to <code>~0</code>.) Since the annotation key is<code>cert-manager.io/cluster-issuer</code>, the pointer becomes <code>cert-manager.io~1cluster-issuer</code>. Get this wrong, and the patch silently targets a path that doesn't exist.</p>
<p><strong>Namespace labels:</strong> The <code>app.kubernetes.io/managed-by=pr-preview</code> label is what makes the reaper in Step 21 safe. A cleanup job that deletes namespaces matching a name pattern is one bad regex away from deleting something important. A job that only ever touches namespaces carrying a label it applied itself has a much smaller worst case.</p>
<p><strong>Guardrails before deploy:</strong> The ResourceQuota must exist before the application deploys, not after. Applying it afterwards means the first deployment escapes it entirely, and you'd only find out when something exceeded a limit that was never enforced.</p>
<p><code>continue-on-error: true</code> <strong>on the certificate wait:</strong> This is deliberate. Certificate issuance depends on Let's Encrypt, an external service with rate limits and occasional slowness. If issuance is delayed, the environment is still deployed and still works over HTTP.</p>
<p>So failing the entire workflow would be wrong. We note the problem and continue. The PR comment in the next step reflects whether the certificate is actually ready.</p>
<h2 id="heading-step-20-comment-the-url-on-the-pull-request">Step 20: Comment the URL on the Pull Request</h2>
<p>The environment is live. Now it's time to tell people about it.</p>
<p>Add this step to the end of the deploy job:</p>
<pre><code class="language-yaml">      - name: Comment preview URL on PR
        uses: actions/github-script@v7
        with:
          script: |
            const host = process.env.PREVIEW_HOST;
            const sha = process.env.SHORT_SHA;
            const ns = process.env.NAMESPACE;

            // A hidden marker so we can find our own comment later
            const marker = '&lt;!-- pr-preview-environment --&gt;';

            const body = [
              marker,
              '### Preview environment is ready',
              '',
              `**https://${host}**`,
              '',
              `Commit \`${sha}\` · Namespace \`${ns}\``,
              '',
              '&lt;sub&gt;This environment updates on every push and is deleted when the PR closes.&lt;/sub&gt;',
            ].join('\n');

            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });

            const existing = comments.find(c =&gt; c.body.includes(marker));

            if (existing) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: existing.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }
</code></pre>
<p>So why this approach?</p>
<p><code>actions/github-script</code> is maintained by GitHub itself and gives you an authenticated Octokit client with no setup. There are third-party actions that post PR comments in fewer lines, but every third-party action in a workflow with write permissions is a supply chain dependency.</p>
<p>Given the effort we spent in Step 5 removing a long-lived credential, adding an unaudited action that can write to your repository would be an odd trade.</p>
<p><strong>The hidden marker comment</strong> is the mechanism that makes this a <em>sticky</em> comment. <code>&lt;!-- pr-preview-environment --&gt;</code> renders as nothing in GitHub's markdown, but it's present in the comment body. On each run, we list the PR's comments, look for one containing the marker, and update it if found.</p>
<p>Without this, a PR with fifteen commits gets fifteen near-identical bot comments, and the thread becomes unreadable. Reviewers start ignoring bot comments entirely, which defeats the purpose. One comment that stays current is the difference between a useful tool and noise.</p>
<p><code>process.env.PREVIEW_HOST</code>: values written to <code>$GITHUB_ENV</code> in earlier steps are available as environment variables in the script. This is cleaner than interpolating <code>${{ }}</code> expressions directly into JavaScript, which can break in surprising ways if a value contains quotes or backticks.</p>
<p><code>context.issue.number</code> works on <code>pull_request</code> events because GitHub treats pull requests as a specialised kind of issue. It's why we use the <code>issues</code> API to comment on a PR.</p>
<p>The step needs <code>pull-requests: write</code>, which the workflow already declares.</p>
<h2 id="heading-step-21-build-the-reaper">Step 21: Build the Reaper</h2>
<p>Here's the uncomfortable truth about the teardown workflow from Part 1: it will eventually fail to run.</p>
<p>The failure modes are mundane. Someone closes a PR while GitHub Actions is having an incident. A workflow file gets edited on a branch in a way that breaks the teardown job. Someone deletes a branch without closing its PR. A repository is archived with PRs still open. GitHub's event delivery hiccups.</p>
<p>None of these are common. All of them leave a namespace running indefinitely, consuming cluster capacity and (if it happens enough) real money. Cleanup that works 95% of the time is still a leak.</p>
<p>The fix is a <strong>reaper</strong>: a scheduled job that periodically compares what's running against what should be running, and deletes the difference.</p>
<p>Create <code>.github/workflows/preview-reaper.yml</code>:</p>
<pre><code class="language-yaml">name: Reap Orphaned Preview Environments

on:
  schedule:
    # 03:00 UTC daily
    - cron: '0 3 * * *'
  # Allow manual runs from the Actions tab
  workflow_dispatch:

permissions:
  contents: read
  id-token: write
  pull-requests: read

env:
  PROJECT_ID: YOUR_PROJECT_ID
  CLUSTER_NAME: pr-preview-cluster
  CLUSTER_ZONE: europe-west2-a
  # Delete environments older than this even if the PR is still open
  MAX_AGE_DAYS: 7

jobs:
  reap:
    name: Delete orphaned namespaces
    runs-on: ubuntu-latest

    steps:
      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
          service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}

      - name: Get GKE credentials
        uses: google-github-actions/get-gke-credentials@v2
        with:
          cluster_name: ${{ env.CLUSTER_NAME }}
          location: ${{ env.CLUSTER_ZONE }}

      - name: Reap
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -euo pipefail

          echo "Fetching open pull requests..."
          OPEN_PRS=$(gh pr list \
            --repo "${{ github.repository }}" \
            --state open \
            --limit 500 \
            --json number \
            --jq '.[].number')

          echo "Open PRs: ${OPEN_PRS:-none}"
          echo ""

          # Only consider namespaces we created ourselves.
          NAMESPACES=$(kubectl get namespaces \
            -l app.kubernetes.io/managed-by=pr-preview \
            -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.labels.preview\.pr}{"\t"}{.metadata.creationTimestamp}{"\n"}{end}')

          if [ -z "${NAMESPACES}" ]; then
            echo "No preview namespaces found. Nothing to do."
            exit 0
          fi

          NOW=$(date -u +%s)
          MAX_AGE_SECONDS=$(( MAX_AGE_DAYS * 86400 ))
          DELETED=0

          while IFS=$'\t' read -r NS PR_NUM CREATED; do
            [ -z "${NS}" ] &amp;&amp; continue

            CREATED_EPOCH=$(date -u -d "${CREATED}" +%s)
            AGE=$(( NOW - CREATED_EPOCH ))
            AGE_DAYS=$(( AGE / 86400 ))

            REASON=""

            # Rule 1: the PR is no longer open
            if ! echo "${OPEN_PRS}" | grep -qx "${PR_NUM}"; then
              REASON="PR #${PR_NUM} is not open"
            # Rule 2: the environment has outlived MAX_AGE_DAYS
            elif [ "${AGE}" -gt "${MAX_AGE_SECONDS}" ]; then
              REASON="environment is ${AGE_DAYS} days old (max ${MAX_AGE_DAYS})"
            fi

            if [ -n "${REASON}" ]; then
              echo "DELETING ${NS} — ${REASON}"
              kubectl delete namespace "${NS}" \
                --ignore-not-found=true \
                --wait=false
              DELETED=$(( DELETED + 1 ))
            else
              echo "KEEPING  ${NS} — PR #${PR_NUM} open, ${AGE_DAYS}d old"
            fi
          done &lt;&lt;&lt; "${NAMESPACES}"

          echo ""
          echo "Reaped ${DELETED} namespace(s)."
</code></pre>
<h3 id="heading-where-does-githubtoken-come-from">Where does <code>GITHUB_TOKEN</code> come from?</h3>
<p>Unlike <code>WIF_PROVIDER</code> and <code>WIF_SERVICE_ACCOUNT</code>, which you created in Step 6, <code>GITHUB_TOKEN</code> <strong>isn't something you set up</strong>. You won't find it on the secrets page, and you shouldn't add it there.</p>
<p>GitHub mints a fresh installation token at the start of every workflow run, exposes it as <code>secrets.GITHUB_TOKEN</code>, and revokes it the moment the job finishes. It's scoped to the repository running the workflow and nothing else.</p>
<h3 id="heading-understanding-the-reaper">Understanding the Reaper</h3>
<p>The reaper runs from GitHub Actions, not inside the cluster. A CronJob in Kubernetes would need a GitHub token stored as a cluster Secret to query open PRs. That's a long-lived credential sitting in the cluster, which is exactly what we avoided in Step 5.</p>
<p>A scheduled workflow already has <code>GITHUB_TOKEN</code> (scoped, automatic, short-lived) and already authenticates to GCP through Workload Identity Federation. No new secrets.</p>
<p>The label selector is the safety mechanism. The query is <code>-l app.kubernetes.io/managed-by=pr-preview</code>, not a name pattern like <code>pr-*</code>. This is a deliberate choice: a name-pattern match would happily delete a production namespace someone named <code>pr-routing-service</code>. The reaper can only ever see namespaces that the deploy workflow explicitly labelled, which means the set of things it could possibly delete is the set of things it created.</p>
<p>Be careful when you adapt this. Any cleanup automation should operate on a positive allowlist it controls, never a pattern that could match something it didn't create.</p>
<p>We also have two deletion rules, for two different problems. Rule 1 catches the actual orphan. A namespace whose PR is closed but whose teardown never ran. Rule 2 catches the stale environment: a PR that's been open for three weeks, hasn't been touched in two, and is quietly holding cluster capacity. Seven days is a reasonable default. If someone needs their environment back, they just push a commit, and it redeploys in a couple of minutes.</p>
<p><code>set -euo pipefail</code>: <code>-e</code> exits on any command failure, <code>-u</code> errors on undefined variables, and <code>-o pipefail</code> makes a pipeline fail if <em>any</em> stage fails rather than only the last. In a script whose job is deleting things, silent failure is the worst outcome. If something goes wrong, stop immediately.</p>
<p><code>grep -qx</code>: the <code>-x</code> flag requires the whole line to match. Without it, PR <code>4</code> would match against open PR <code>42</code> and the reaper would spare an orphaned namespace forever. It's a small flag, but a real bug.</p>
<p><code>workflow_dispatch</code> lets you run the reaper manually from the Actions tab. Do that the first few times and read the output before trusting the schedule. The log prints <code>KEEPING</code> or <code>DELETING</code> with a reason for every namespace, so you can verify the logic matches your expectations before it runs unattended.</p>
<h2 id="heading-step-22-test-the-whole-thing">Step 22: Test the Whole Thing</h2>
<p>Commit everything and push:</p>
<pre><code class="language-bash">git add .
git commit -m "Add Ingress, TLS, PR comments, guardrails, and reaper"
git push
</code></pre>
<p>Create a branch, make a visible change, and open a pull request:</p>
<pre><code class="language-bash">git checkout -b test-preview-urls
# edit app/main.go — change the greeting text
git add .
git commit -m "Test preview URLs"
git push -u origin test-preview-urls
</code></pre>
<p>Open the PR and watch the Actions tab.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/5c13299f-c72c-4073-8ab0-2f552749cee5.png" alt="The deploy workflow running with the new certificate and comment step" style="display: block;" width="600" height="400" loading="lazy">

<p>When it finishes, the bot comment should appear with your URL. Open it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/652f2033-7672-4f32-9a53-e2837c97bab2.png" alt="The bot comment showing the new URL" style="display: block;" width="600" height="400" loading="lazy">

<p>Because you're still on the staging issuer, your browser will show a certificate warning. Something like "Your connection is not private," with an issuer of "Let's Encrypt Staging."</p>
<p><strong>This is the success condition.</strong> It means DNS resolved, the load balancer routed, the Ingress matched the hostname, the HTTP-01 challenge completed, and a certificate was issued. Everything worked. The certificate simply comes from an untrusted test authority.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/4e0e252f-66e4-47fa-a0cf-31a0c491a73a.png" alt="Browser security warning for the preview environment URL, showing the certificate was issued by Let's Encrypt Staging rather than a trusted authority" style="display: block;" width="600" height="400" loading="lazy">

<p>Click Advanced, pass through the warning, and confirm the application responds with the right PR number and namespace.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/094f0273-1a55-4d84-ac42-b3dca2b8ce58.png" alt="The preview environment loading in the browser, showing the application's output with the correct PR number, commit SHA, and namespace" style="display: block;" width="600" height="400" loading="lazy">

<p>Check the certificate from the command line:</p>
<pre><code class="language-bash">kubectl get certificate --namespace=pr-2 
#Ensure you use your own pr-number
</code></pre>
<p>You want <code>READY: True</code>. If it's <code>False</code>, inspect what's happening:</p>
<pre><code class="language-bash">kubectl describe certificate preview-app-tls --namespace=pr-2
kubectl get challenges --all-namespaces
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/d27a623d-f1fc-49bd-a1eb-69d277a17631.png" alt="Checking the certificate from the command line" style="display: block;" width="600" height="400" loading="lazy">

<p>A pending challenge that never resolves almost always means DNS or port 80 reachability. Confirm with:</p>
<pre><code class="language-bash">curl -I http://pr-2.preview.yourdomain.com/.well-known/acme-challenge/test
</code></pre>
<p>You want any HTTP response from NGINX. A 404 is fine, as it proves that the routing works. A connection timeout means the request isn't reaching your cluster.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/5b539f11-bc2b-46e8-853d-7f8b9d783b8e.png" alt="Confirming DNS reachability" style="display: block;" width="600" height="400" loading="lazy">

<h3 id="heading-switch-to-production-certificates">Switch to Production Certificates</h3>
<p>Once the staging flow works end to end, change one line in the workflow's <code>env:</code> block:</p>
<pre><code class="language-yaml">  CLUSTER_ISSUER: letsencrypt-prod
</code></pre>
<p>Make this change <strong>on your existing PR branch</strong> and push it. You don't need to close the pull request or merge to <code>main</code> first. You can also change the message in your main.go file to confirm everything works.</p>
<p>Push a new commit to the PR. The environment redeploys, cert-manager requests a real certificate, and this time the browser shows a padlock.</p>
<p>Open the URL in a <strong>private browsing window</strong> to check. Your browser cached an exception for the staging certificate against that hostname, and a normal tab may show a trusted-looking page that's really just a remembered override and still shows <code>not secure</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/54b5979c-f3d9-4dca-a63d-970e4e66e219.png" alt="The preview environment loading over HTTPS with a valid certificate" style="display: block;" width="600" height="400" loading="lazy">

<h3 id="heading-verify-the-guardrails">Verify the Guardrails</h3>
<p>Confirm the quota is active:</p>
<pre><code class="language-bash">kubectl describe resourcequota preview-quota --namespace=pr-2
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/9d50f2e6-9802-424e-8be7-7955ba8dcd92.png" alt="Output of kubectl describe resourcequota showing the preview-quota in namespace pr-1 with used and hard limits listed for CPU, memory, pods, services, and load balancers" style="display: block;" width="600" height="400" loading="lazy">

<p>You'll see used versus hard limits for each resource. Then try to break it:</p>
<pre><code class="language-bash">kubectl scale deployment/preview-app --replicas=50 --namespace=pr-2
kubectl get events --namespace=pr-2 | grep -i quota
</code></pre>
<p>The deployment will scale partway and then stop, with events explaining that it exceeded the quota. That's the guardrail doing its job: the namespace is bounded, and the rest of the cluster is unaffected.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/13872121-d7a9-49e5-b85f-34a629afc77a.png" alt="Terminal output showing the deployment scaled only partway to 50 replicas, with Kubernetes events reporting that the request exceeded the namespace quota" style="display: block;" width="600" height="400" loading="lazy">

<h3 id="heading-verify-teardown-and-the-reaper">Verify Teardown and the Reaper</h3>
<p>Close the pull request. The teardown workflow fires and the namespace goes to <code>Terminating</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/336470b2-df0d-4f14-bab3-6f17f38ad4f3.png" alt="Github Actions teardown workflow job completed" style="display: block;" width="600" height="400" loading="lazy">

<p>Then test the reaper on a namespace it <em>shouldn't</em> touch. Create one manually without the label:</p>
<pre><code class="language-bash">kubectl create namespace pr-9999
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/ac2c98c2-f8fc-4cbb-b5e9-8cae679fd4cf.png" alt="Terminal showing kubectl create namespace pr-9999, creating a namespace without the pr-preview management label" style="display: block;" width="600" height="400" loading="lazy">

<p>Run the reaper manually from the Actions tab.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/4941adde-9be6-4870-8742-47920ffa8f97.png" alt="GitHub Actions interface showing the Reap Orphaned Preview Environments workflow with the Run workflow button available through workflow_dispatch" style="display: block;" width="600" height="400" loading="lazy">

<p>It should ignore <code>pr-9999</code> entirely, because that namespace carries no <code>app.kubernetes.io/managed-by=pr-preview</code> label. The log won't even mention it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/a16298d1-c63a-45f3-b117-a35ba79fc9e9.png" alt="GitHub Actions log for the reaper showing it found no preview namespaces to act on, having ignored the unlabelled pr-9999 namespace entirely" style="display: block;" width="600" height="400" loading="lazy">

<p>Clean it up yourself:</p>
<pre><code class="language-bash">kubectl delete namespace pr-9999
</code></pre>
<p>That test matters more than it looks. You've just verified that your automated deletion job can't touch things it didn't create.</p>
<h2 id="heading-what-this-costs">What This Costs</h2>
<p>We should be concrete about this, since cost control is half the argument for this pattern.</p>
<p>Running continuously in <code>europe-west2</code>, here are the approximate monthly figures:</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Approximate cost</th>
</tr>
</thead>
<tbody><tr>
<td>GKE control plane</td>
<td>~$74/month (one zonal cluster, free tier covers one cluster on some accounts)</td>
</tr>
<tr>
<td>2 × <code>e2-standard-2</code> nodes</td>
<td>~$97/month</td>
</tr>
<tr>
<td>Regional load balancer</td>
<td>~$18/month + data processing</td>
</tr>
<tr>
<td>Static IP (in use)</td>
<td>Free while attached</td>
</tr>
<tr>
<td>Artifact Registry storage</td>
<td>~$0.10/GB/month</td>
</tr>
<tr>
<td>Let's Encrypt certificates</td>
<td>Free</td>
</tr>
<tr>
<td>GitHub Actions (public repo)</td>
<td>Free</td>
</tr>
</tbody></table>
<p>Call it roughly $190/month for the always-on infrastructure, regardless of how many preview environments you run.</p>
<p>The number that matters is what <em>doesn't</em> scale with PR count. One load balancer serves every environment. One DNS record covers every hostname. Certificates are free. The marginal cost of the eleventh preview environment is only the node capacity its pods consume, a few pence a day for an application this size.</p>
<p>Compare that with the naïve approach of a <code>LoadBalancer</code> Service per environment: ten open PRs would add roughly $180/month in load balancers alone, and that's before anyone forgets to clean one up. The <code>services.loadbalancers: "0"</code> line in the ResourceQuota exists specifically to make that mistake impossible.</p>
<p>There are two ways to cut the standing cost for a personal project. Use <strong>spot nodes</strong>, add <code>spot = true</code> to the <code>node_config</code> block in Terraform for roughly a 60–70% discount on node hours. Preview environments are the ideal spot workload: if a node is reclaimed, pods reschedule, and the worst case is a preview environment briefly restarting. Or scale the node pool to zero overnight with a scheduled workflow, since nobody reviews PRs at 3 am.</p>
<h2 id="heading-cleanup">Cleanup</h2>
<p>When you're finished:</p>
<pre><code class="language-bash"># Delete every preview namespace
kubectl delete namespaces -l app.kubernetes.io/managed-by=pr-preview

# Remove the shared infrastructure
helm uninstall cert-manager --namespace cert-manager
helm uninstall ingress-nginx --namespace ingress-nginx
kubectl delete namespace cert-manager ingress-nginx
</code></pre>
<p>Uninstall the Helm releases <em>before</em> running <code>terraform destroy</code>. The Ingress controller's Service owns a Google Cloud load balancer that Terraform doesn't know about, because Kubernetes created it rather than Terraform. Destroying the cluster first can orphan that load balancer, leaving it billing quietly in a project with nothing else in it.</p>
<h3 id="heading-if-you-plan-to-rebuild-this-later">If You Plan to Rebuild This Later</h3>
<p><strong>Don't run a full</strong> <code>terraform destroy</code><strong>.</strong> Tear down only the expensive parts:</p>
<pre><code class="language-bash">cd terraform 
terraform destroy 
-var="project_id=YOUR_PROJECT_ID" 
-var="github_repo=YOUR_USERNAME/pr-preview-envs"
</code></pre>
<p>Here's why. When you delete a Workload Identity Pool, Google doesn't remove it immediately. It marks it <code>DELETED</code> and reserves the ID for 30 days. Terraform forgets about it, but Google hasn't. Recreating the stack within that window fails with:</p>
<pre><code class="language-plaintext">Error: Error creating WorkloadIdentityPool: googleapi: Error 409: Requested entity already exists
</code></pre>
<p>Almost all of your cost is the cluster and its nodes. The identity pool, provider, service account, IAM bindings, static IP, and Artifact Registry repository cost nothing or pennies to leave in place. Rebuilding later is then just <code>terraform apply</code>, and your <code>WIF_PROVIDER</code> and <code>WIF_SERVICE_ACCOUNT</code> secrets in GitHub stay valid. No need to update them.</p>
<p>If you want to keep the cluster but stop paying for idle nodes, scale the node pool to zero instead:</p>
<pre><code class="language-bash">gcloud container clusters resize pr-preview-cluster
--node-pool pr-preview-cluster-pool
--num-nodes 0
--zone europe-west2-a
</code></pre>
<p>You still pay for the GKE control plane, so this reduces cost rather than eliminating it.</p>
<p>If you're done for good:</p>
<pre><code class="language-bash">cd terraform
terraform destroy
-var="project_id=YOUR_PROJECT_ID"
-var="github_repo=YOUR_USERNAME/pr-preview-envs" 
</code></pre>
<p>Then confirm nothing was left behind:</p>
<pre><code class="language-bash">gcloud compute forwarding-rules list
gcloud compute addresses list
</code></pre>
<p>Both should be empty, or contain only resources belonging to other projects of yours. Don't forget to remove the wildcard DNS record too.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You've built something that most engineering teams either don't have or pay a vendor for.</p>
<p>You wrote a Go application that reports its own build metadata, injected at compile time through linker flags, so a running environment can always tell you exactly which commit it came from. You wrapped it in a multi-stage Dockerfile that produces a small, statically linked, non-root image</p>
<p>You provisioned a GKE cluster and an Artifact Registry repository with Terraform, which means the whole environment is reproducible and destroyable with a single command. You set up Workload Identity Federation so GitHub Actions authenticates to Google Cloud with short-lived OIDC tokens instead of a long-lived service account key, scoped to exactly one repository by an attribute condition.</p>
<p>You wrote base Kubernetes manifests once and used Kustomize overlays, generated at runtime, to deploy the same application into a different namespace with a different image tag and a different hostname for every pull request. You put a single shared Ingress controller in front of every environment and gave it a stable static IP, so one load balancer serves any number of previews. You pointed one wildcard DNS record at it, which means a new environment needs no DNS work at all: routing happens on the <code>Host</code> header inside the cluster. You added cert-manager so every environment gets a valid, auto-renewing TLS certificate, and made the pipeline announce itself with a sticky PR comment that updates in place.</p>
<p>You bounded each namespace with a ResourceQuota and LimitRange, including one line that makes the most expensive possible mistake structurally impossible. And you built a reaper that catches what the happy path misses, operating only on namespaces it can prove it created.</p>
<p>The result is that an isolated, production-shaped, publicly reachable environment is the <strong>default</strong> for every change, not something a developer has to request, claim in a Slack channel, or remember to clean up.</p>
<h3 id="heading-where-to-go-from-here">Where to Go From Here</h3>
<p>There are a few directions you can explore. Here they are, roughly in order of how much they'd improve a real team's experience:</p>
<ol>
<li><p><strong>Seed data for each environment.</strong> The biggest limitation of what we built is that the application is stateless. Real applications need a database. The common patterns are a per-namespace ephemeral database seeded from a sanitised snapshot, or a shared database with per-environment schema isolation. This is the single hardest problem in preview environments and where most of the remaining engineering effort goes.</p>
</li>
<li><p><strong>Run your test suite against the preview.</strong> Once the environment has a real URL, end-to-end tests can run against it in CI, a far stronger signal than tests against mocks.</p>
</li>
<li><p><strong>Wildcard certificates via DNS-01.</strong> As covered in Step 15, this removes the Let's Encrypt rate limit ceiling once your PR volume approaches it.</p>
</li>
<li><p><strong>Move to GitOps.</strong> Rather than the workflow running <code>kubectl apply</code> directly, have it commit the rendered manifests to a repository that Argo CD or Flux reconciles. The cluster's state then has a git history, and your CI pipeline no longer needs cluster write access at all.</p>
</li>
<li><p><strong>Preview environment metrics.</strong> Hook the namespaces into the cost visibility tooling you'd use elsewhere, and you can answer "what do preview environments actually cost us per month" with a number rather than a shrug.</p>
</li>
</ol>
<h2 id="heading-project-file-structure">Project File Structure</h2>
<pre><code class="language-plaintext">pr-preview-envs/
├── app/
│   ├── main.go                      — Go HTTP server reporting build metadata
│   ├── go.mod                       — Go module definition
│   └── Dockerfile                   — Multi-stage build with ldflags injection
├── terraform/
│   ├── main.tf                      — GKE, Artifact Registry, WIF, static IP
│   ├── variables.tf                 — Input variables
│   └── outputs.tf                   — Cluster, WIF, and ingress IP values
├── k8s/
│   ├── base/
│   │   ├── deployment.yaml          — Deployment with probes and Downward API
│   │   ├── service.yaml             — ClusterIP Service
│   │   ├── ingress.yaml             — Ingress with cert-manager annotation
│   │   └── kustomization.yaml       — Base Kustomize config
│   └── cluster/
│       ├── letsencrypt-staging.yaml — Staging ClusterIssuer
│       ├── letsencrypt-prod.yaml    — Production ClusterIssuer
│       └── namespace-guardrails.yaml — ResourceQuota and LimitRange
└── .github/
    └── workflows/
        ├── preview-deploy.yml       — Build, deploy, certificate, PR comment
        ├── preview-teardown.yml     — Delete namespace on PR close
        └── preview-reaper.yml       — Scheduled orphan cleanup
</code></pre>
<p>All source files for this tutorial are available in the companion GitHub repository: <a href="https://github.com/Amiynarh/pr-preview-envs"><code>https://github.com/Amiynarh/pr-preview-envs</code></a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Containerize a Node.js Application with Docker and Deploy with GitHub Actions ]]>
                </title>
                <description>
                    <![CDATA[ If you've been building Node.js projects, you've probably had an experience like this. The project runs fine on your machine, but when you push it to a server, something breaks. Maybe it's a different ]]>
                </description>
                <link>https://www.freecodecamp.org/news/containerize-a-node-js-app-with-docker-and-deploy-with-github-actions/</link>
                <guid isPermaLink="false">6a569b9cbd138d774dee2042</guid>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub Actions ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker compose ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker-compose.yml ]]>
                    </category>
                
                    <category>
                        <![CDATA[ containerization ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Backend Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Tue, 14 Jul 2026 20:27:08 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/343864e6-5319-4378-a2b1-4955e38ad6d8.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've been building <a href="https://www.freecodecamp.org/news/role-based-access-control-nodejs-rest-api-jwt/">Node.js projects</a>, you've probably had an experience like this. The project runs fine on your machine, but when you push it to a server, something breaks.</p>
<p>Maybe it's a different Node version, maybe an environment variable is missing, or maybe a system dependency doesn't match. You spend an hour debugging something that was never actually a code problem.</p>
<p>Docker fixes this at the root. With Docker, you stop shipping just code. The Node version, dependencies, and config all travel inside the container. Your laptop, a CI server, a production VM — it behaves the same on all of them. No more environment surprises.</p>
<p>In this tutorial, we'll go through all this step by step: a multi-stage Dockerfile, using Docker Compose with PostgreSQL for local development, and a GitHub Actions workflow that pushes a fresh image to Docker Hub on every merge to <code>main</code>.</p>
<p>The complete code for this tutorial is available on <a href="https://github.com/ziaongit/nodejs-docker-cicd">GitHub</a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-sample-application">The Sample Application</a></p>
</li>
<li><p><a href="#heading-writing-the-dockerfile">Writing the Dockerfile</a></p>
</li>
<li><p><a href="#heading-the-dockerignore-file">The .dockerignore File</a></p>
</li>
<li><p><a href="#heading-the-gitignore-file">The .gitignore File</a></p>
</li>
<li><p><a href="#heading-build-and-test-the-image-locally">Build and Test the Image Locally</a></p>
</li>
<li><p><a href="#heading-docker-compose-for-local-development">Docker Compose for Local Development</a></p>
</li>
<li><p><a href="#heading-automate-the-build-with-github-actions">Automate the Build with GitHub Actions</a></p>
</li>
<li><p><a href="#heading-deploying-the-image">Deploying the Image</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 18+</p>
</li>
<li><p>Docker Desktop, which you can download at <a href="https://docs.docker.com/get-docker/">docs.docker.com/get-docker</a>. Windows users need WSL 2 before Docker starts. Open PowerShell as Administrator and run <code>wsl --install</code>. After the restart, Docker Desktop will install without issues.</p>
</li>
<li><p>A GitHub account</p>
</li>
<li><p>A Docker Hub account (free at <a href="https://hub.docker.com">hub.docker.com</a>)</p>
</li>
<li><p>Some Express.js experience helps, but isn't required</p>
</li>
</ul>
<h2 id="heading-the-sample-application">The Sample Application</h2>
<p>We're building a task management API with Express and PostgreSQL. Keep in mind the app is just a vehicle to teach you how this works. The Dockerfile and pipeline we set up here work the same way for any Node.js project.</p>
<p>Create the project:</p>
<pre><code class="language-bash">mkdir nodejs-docker-cicd &amp;&amp; cd nodejs-docker-cicd
npm init -y
npm install express pg dotenv
npm install --save-dev nodemon
</code></pre>
<p>Create <code>src/index.js</code>:</p>
<pre><code class="language-javascript">const express = require('express');
const { Pool } = require('pg');
require('dotenv').config();

const app = express();
app.use(express.json());

const pool = new Pool({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
});

// Create table on startup
pool.query(`
  CREATE TABLE IF NOT EXISTS tasks (
    id SERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    completed BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT NOW()
  )
`).catch(console.error);

// Health check — required for Docker HEALTHCHECK and load balancers
app.get('/health', (req, res) =&gt; {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.get('/tasks', async (req, res) =&gt; {
  try {
    const result = await pool.query('SELECT * FROM tasks ORDER BY created_at DESC');
    res.json(result.rows);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.post('/tasks', async (req, res) =&gt; {
  const { title } = req.body;
  if (!title) return res.status(400).json({ error: 'Title is required' });
  try {
    const result = await pool.query(
      'INSERT INTO tasks (title) VALUES ($1) RETURNING *',
      [title]
    );
    res.status(201).json(result.rows[0]);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.patch('/tasks/:id', async (req, res) =&gt; {
  const { id } = req.params;
  const { completed } = req.body;
  try {
    const result = await pool.query(
      'UPDATE tasks SET completed = $1 WHERE id = $2 RETURNING *',
      [completed, id]
    );
    if (result.rows.length === 0) return res.status(404).json({ error: 'Task not found' });
    res.json(result.rows[0]);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () =&gt; console.log(`Server running on port ${PORT}`));
</code></pre>
<p>Open <code>package.json</code> and update the <code>"scripts"</code> section:</p>
<pre><code class="language-json">"scripts": {
  "start": "node src/index.js",
  "dev": "nodemon src/index.js"
}
</code></pre>
<p><code>npm start</code> runs the app directly with Node. <code>npm run dev</code> uses nodemon so the server restarts automatically when you edit a file.</p>
<p>For running without Docker, create a <code>.env</code> file:</p>
<pre><code class="language-plaintext">DB_HOST=localhost
DB_PORT=5432
DB_NAME=tasksdb
DB_USER=postgres
DB_PASSWORD=yourpassword
PORT=3000
</code></pre>
<p>Notice that all database credentials come from environment variables rather than being hardcoded. Swap the variables, and the same image runs against your local database or a production one — no code changes needed. The <code>/health</code> endpoint is what Docker pings to know the app is actually handling requests.</p>
<h2 id="heading-writing-the-dockerfile">Writing the Dockerfile</h2>
<p>Before touching the Dockerfile, there are two terms you'll keep seeing. An <strong>image</strong> is a packaged, immutable version of your app — Node runtime, code, dependencies, everything together in one artifact. A <strong>container</strong> is a running instance of that image. One image, many containers, any machine.</p>
<p>Here's the Dockerfile we'll use:</p>
<pre><code class="language-dockerfile"># ── Stage 1: Install dependencies ──────────────────────────────────────────
FROM node:18-alpine AS builder

WORKDIR /app

# Copy package files first — Docker caches this layer separately.
# If you only change src code (not package.json), Docker skips npm ci on rebuild.
COPY package*.json ./
RUN npm ci

COPY . .


# ── Stage 2: Production image ───────────────────────────────────────────────
FROM node:18-alpine AS production

# Create a non-root user — running as root inside a container is a security risk
RUN addgroup -g 1001 -S nodejs &amp;&amp; \
    adduser -S nodeuser -u 1001

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

# Copy only the source code from the builder stage (not node_modules or dev files)
COPY --from=builder /app/src ./src

RUN chown -R nodeuser:nodejs /app
USER nodeuser

EXPOSE 3000

# Docker will ping /health every 30s. If it fails 3 times, the container is marked unhealthy.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node", "src/index.js"]
</code></pre>
<p>This is a multi-stage build. The first stage (<code>builder</code>) installs everything, including dev dependencies. The second stage (<code>production</code>) starts fresh and only copies what the app needs to run. Nodemon, test frameworks, and anything else dev-only never make it into the final image.</p>
<p>The size difference is real. A <code>node:18</code> Debian image is over 950MB. Switch to <code>node:18-alpine</code> and cut out the dev dependencies, and the final image lands around 150–200MB instead. A smaller image means faster pushes and faster deploys.</p>
<p><code>npm ci</code> instead of <code>npm install</code> is a deliberate choice for CI/CD. It reads exact versions from <code>package-lock.json</code> and fails hard if the lockfile doesn't match <code>package.json</code>. Every build on every machine installs the exact same versions — no surprises from a dependency that quietly updated overnight.</p>
<p>The <code>nodeuser</code> account exists because containers run as root by default. That's fine until something goes wrong. A non-root user means that an attacker who gets inside the container can't just do whatever they want.</p>
<h2 id="heading-the-dockerignore-file">The <code>.dockerignore</code> File</h2>
<p>Create <code>.dockerignore</code> before building:</p>
<pre><code class="language-plaintext">node_modules
npm-debug.log
.env
.git
.gitignore
README.md
Dockerfile
.dockerignore
</code></pre>
<p>The <code>node_modules</code> exclusion is the critical one. Your local modules were compiled for your operating system — macOS or Windows binaries won't work inside a Linux container. Excluding them means Docker installs fresh modules during the build, compiled for the correct platform. Without this exclusion, you'd either copy broken binaries into the image or waste time uploading hundreds of megabytes to the build context.</p>
<p>Never put <code>.env</code> in an image. Passwords, API keys, anything sensitive — those go in at runtime as environment variables, never inside the image itself.</p>
<h2 id="heading-the-gitignore-file">The <code>.gitignore</code> File</h2>
<p>One more thing before the first commit: a <code>.gitignore</code>. You don't want <code>node_modules</code> or <code>.env</code> tracked:</p>
<pre><code class="language-plaintext">node_modules/
.env
.env.local
npm-debug.log*
logs/
.DS_Store
Thumbs.db
.vscode/
.idea/
dist/
build/
</code></pre>
<h2 id="heading-build-and-test-the-image-locally">Build and Test the Image Locally</h2>
<p>Open Docker Desktop first and give it a moment. On Windows, you'll see a whale icon in the taskbar that animates while the engine is starting up. Once it goes still, you're good to run Docker commands. If you try to run Docker before the engine is up, you'll hit this:</p>
<pre><code class="language-plaintext">ERROR: Error response from daemon: Docker Desktop is unable to start
</code></pre>
<p>If that happens, quit Docker Desktop. Open PowerShell as Administrator, run <code>wsl --update</code>, and restart. Then go to Control Panel → Programs → Turn Windows features on or off. Both Hyper-V and Virtual Machine Platform need to be checked. After the restart, Docker Desktop should come up fine.</p>
<p>It's worth knowing about this error too:</p>
<pre><code class="language-plaintext">docker : The term 'docker' is not recognized as the name of a cmdlet, function,
script file, or operable program.
</code></pre>
<p>This means that Docker Desktop isn't running or isn't installed. Open it from the Start menu and wait.</p>
<p>Run the build:</p>
<pre><code class="language-bash">docker build -t nodejs-docker-cicd:latest .
</code></pre>
<p>The first time takes roughly 30 seconds since Docker has to pull <code>node:18-alpine</code> from the internet. Once that's cached, subsequent builds are much quicker. Both stages will scroll by:</p>
<pre><code class="language-plaintext">[+] Building 33.1s (17/17) FINISHED
 =&gt; [builder 1/5] FROM docker.io/library/node:18-alpine       20.9s
 =&gt; [builder 4/5] RUN npm ci                                   3.5s
 =&gt; [production 5/7] RUN npm ci --only=production              3.2s
 =&gt; [production 7/7] RUN chown -R nodeuser:nodejs /app         3.2s
 =&gt; exporting to image                                         1.5s
 =&gt; =&gt; naming to docker.io/library/nodejs-docker-cicd:latest     0.0s
</code></pre>
<p>When you see <code>(17/17) FINISHED</code> the image is built. Check the size:</p>
<pre><code class="language-bash">docker images nodejs-docker-cicd
</code></pre>
<pre><code class="language-plaintext">IMAGE                     ID             DISK USAGE   CONTENT SIZE
nodejs-docker-cicd:latest   c9eed311d999        198MB         47.5MB
</code></pre>
<p><strong>CONTENT SIZE</strong> (47.5MB) is the compressed size that gets pushed to Docker Hub. <strong>DISK USAGE</strong> (198MB) is what it takes up on disk locally. Compare that to a <code>node:18</code> Debian image at 950MB+, and you can see why the Alpine base and multi-stage approach matter.</p>
<p>On subsequent builds, Docker reuses cached layers. Edit only your source files without touching <code>package.json</code> and the <code>npm ci</code> step gets skipped completely. That 33-second first build becomes 3 seconds.</p>
<h2 id="heading-docker-compose-for-local-development">Docker Compose for Local Development</h2>
<p>The app needs a database. Setting up PostgreSQL locally means every developer who clones the repo has to do it, too. Docker Compose handles this: one file defines both services, and one command starts them.</p>
<p>Create <code>docker-compose.yml</code>:</p>
<pre><code class="language-yaml">services:
  app:
    build:
      context: .
      target: production
    ports:
      - '3000:3000'
    environment:
      DB_HOST: postgres
      DB_PORT: 5432
      DB_NAME: tasksdb
      DB_USER: postgres
      DB_PASSWORD: postgres
      PORT: 3000
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: tasksdb
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - '5432:5432'
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres']
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:
</code></pre>
<p>A few things worth pointing out. <code>DB_HOST</code> is set to <code>postgres</code>. That's the service name, not <code>localhost</code>. Containers on the same Docker network reach each other by service name. Put <code>localhost</code> there and the app tries to connect to itself.</p>
<p><code>depends_on</code> with <code>condition: service_healthy</code> holds the app back until Postgres actually passes its health check. Skip this and the app starts, tries to connect to a database that isn't ready yet, and crashes. The health check pings <code>pg_isready</code> every 5 seconds. Once it gets a green response, the app container starts.</p>
<p>The named volume <code>postgres_data</code> keeps your data alive between restarts. Run <code>docker compose down</code> and the data is still there next time. Add <code>--volumes</code> to wipe it clean.</p>
<p>Start both services:</p>
<pre><code class="language-bash">docker compose up --build
</code></pre>
<p>You'll see PostgreSQL initialize and then the app start. Once you see <code>Server running on port 3000</code> in the logs, the stack is up.</p>
<p>Open a second terminal to test — leave the compose logs running in the first one.</p>
<p><strong>Linux/macOS:</strong></p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Docker"}'

curl http://localhost:3000/tasks

curl http://localhost:3000/health
</code></pre>
<p><strong>Windows PowerShell:</strong> Typing <code>curl</code> in PowerShell runs <code>Invoke-WebRequest</code>, not actual curl. Run <code>curl.exe</code> instead. For JSON bodies, write to a file first:</p>
<pre><code class="language-powershell">'{"title": "Learn Docker"}' | Set-Content body.json
curl.exe -X POST http://localhost:3000/tasks -H "Content-Type: application/json" --data `@body.json

curl.exe http://localhost:3000/tasks

curl.exe http://localhost:3000/health
</code></pre>
<p>The backtick before <code>@body.json</code> is necessary. PowerShell would otherwise try to interpret <code>@</code> as a splatting operator rather than passing it to curl as a filename prefix.</p>
<p>You should see responses like these:</p>
<pre><code class="language-json"># POST /tasks
{"id":1,"title":"Learn Docker","completed":false,"created_at":"2026-07-09T22:21:17.073Z"}

# GET /tasks
[{"id":1,"title":"Learn Docker","completed":false,"created_at":"2026-07-09T22:21:17.073Z"}]

# GET /health
{"status":"ok","timestamp":"2026-07-09T22:11:44.700Z"}
</code></pre>
<p>The task hit PostgreSQL in one container and came back through the app. <code>Ctrl+C</code> in the compose terminal stops both.</p>
<h2 id="heading-automate-the-build-with-github-actions">Automate the Build with GitHub Actions</h2>
<p>The image works locally, so it's time to stop doing this by hand.</p>
<h3 id="heading-step-1-create-a-docker-hub-access-token">Step 1: Create a Docker Hub Access Token</h3>
<p>Go to <a href="https://hub.docker.com">hub.docker.com</a> and then Account Settings → Security → New Access Token. Set permission to Read &amp; Write, as read-only breaks the push. The token appears once, so copy it before closing the page.</p>
<p><strong>Security warning:</strong> Don't paste this token into a chat, email, or commit. If you expose it by accident, delete it immediately, then make a new one.</p>
<h3 id="heading-step-2-add-secrets-to-your-github-repository">Step 2: Add Secrets to Your GitHub Repository</h3>
<p>Head to Settings → Secrets and variables → Actions in your repo and add:</p>
<ul>
<li><p><code>DOCKERHUB_USERNAME</code> — your Docker Hub username</p>
</li>
<li><p><code>DOCKERHUB_TOKEN</code> — paste the token here, nowhere else</p>
</li>
</ul>
<p>If you ran into <code>Error: Username and password required</code>, the secrets either aren't saved yet or the names are typed wrong. Both are case-sensitive.</p>
<p>A Node 20 deprecation warning in the logs is normal. It comes from the Docker actions internally, not your code.</p>
<h3 id="heading-step-3-create-the-workflow-file">Step 3: Create the Workflow File</h3>
<p>Create <code>.github/workflows/docker-publish.yml</code>:</p>
<pre><code class="language-yaml">name: Build and Push Docker Image

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/nodejs-docker-cicd

jobs:
  build-and-push:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Docker Hub
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          target: production
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
</code></pre>
<p>The login step has <code>if: github.event_name != 'pull_request'</code>. This skips authentication on pull requests. PRs from forks don't have access to your secrets, so trying to log in would just fail. The build still runs on PRs to validate your Dockerfile, but the image isn't pushed.</p>
<p>The metadata action generates two tags on every merge to <code>main</code>: <code>latest</code> and a short commit SHA like <code>sha-a1b2c3d</code>. The SHA tag is what makes rollbacks practical. If <code>latest</code> breaks in production, you can pull any previous <code>sha-</code> tag and you're back to a known-good state in seconds.</p>
<p>The <code>cache-from/cache-to: type=gha</code> lines store Docker's layer cache in GitHub Actions' built-in cache. The first run builds everything from scratch. After that, unchanged layers are pulled from cache rather than rebuilt. On a typical Node.js app this brings build time from 2–3 minutes down to under 30 seconds.</p>
<h3 id="heading-push-and-watch-it-run">Push and Watch it Run</h3>
<pre><code class="language-bash">git add .
git commit -m "Add Docker configuration and GitHub Actions workflow"
git push origin main
</code></pre>
<p>Go to your repo's <strong>Actions</strong> tab. You'll see the workflow running in real time. Each step turns green as it completes:</p>
<pre><code class="language-plaintext">✅ Checkout code
✅ Set up Docker Buildx
✅ Log in to Docker Hub
✅ Extract metadata
✅ Build and push
</code></pre>
<p>Green across the board means your image is live on Docker Hub — two tags, <code>latest</code> and a commit SHA like <code>sha-a1b2c3d</code>. Every push to <code>main</code> from here builds and ships automatically.</p>
<h2 id="heading-deploying-the-image">Deploying the Image</h2>
<p>With your image on Docker Hub, you can deploy it to any infrastructure:</p>
<p><strong>Any VPS or server:</strong></p>
<pre><code class="language-bash">docker pull yourusername/nodejs-docker-cicd:latest
docker run -d -p 3000:3000 \
  -e DB_HOST=your-db-host \
  -e DB_NAME=tasksdb \
  -e DB_USER=postgres \
  -e DB_PASSWORD=yourpassword \
  yourusername/nodejs-docker-cicd:latest
</code></pre>
<p><strong>Railway</strong> — Connect your Docker Hub image in the Railway dashboard and it deploys on the next push.</p>
<p><strong>Fly.io</strong> — Run <code>fly launch</code> pointing at your Dockerfile and Fly handles the rest.</p>
<p><strong>Render</strong> — Paste your Docker Hub image URL into the Render service settings.</p>
<p>Each push to <code>main</code> runs the workflow. New image goes to Docker Hub, platform picks it up — that's your deployment handled.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>What started as a local Node.js app now runs in a container. You get the same behavior on any machine, real PostgreSQL in development, and a pipeline that builds and ships to Docker Hub without you doing anything after the push.</p>
<p>The multi-stage build keeps the image lean — dev tools stay out, non-root user, health check baked in. Compose gets the full stack up with one command for anyone who clones the repo. The SHA tag on every GitHub Actions build means rolling back is just a matter of pulling an older tag.</p>
<p>These same patterns (multi-stage builds, Compose for local development, automated image publishing) are used across the industry for production Node.js deployments. Pick up these patterns once and they follow you to every project.</p>
<p>From here, you can extend the pipeline: drop a test step in before the build, or add multi-platform support if you're targeting ARM. Once Docker Compose starts feeling limiting in production, that's usually when Kubernetes enters the picture.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Build a Self-Healing CI/CD Pipeline with AI ]]>
                </title>
                <description>
                    <![CDATA[ Learn how how to build a fully autonomous, self-healing CI/CD pipeline from scratch with a new course we just posted on the freeCodeCamp.org YouTube channel. Farzeen Ali developed this course. In this ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-a-self-healing-ci-cd-pipeline-with-ai/</link>
                <guid isPermaLink="false">6a2fef02af88b2b9b6fa784b</guid>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ youtube ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Beau Carnes ]]>
                </dc:creator>
                <pubDate>Mon, 15 Jun 2026 12:24:34 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5f68e7df6dfc523d0a894e7c/045067c8-d191-4677-b026-f7e876944bd6.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Learn how how to build a fully autonomous, self-healing CI/CD pipeline from scratch with a new course we just posted on the <a href="http://freeCodeCamp.org">freeCodeCamp.org</a> YouTube channel. Farzeen Ali developed this course.</p>
<p>In this hands-on project, you will learn to bridge the gap between DevOps and automation by integrating GitHub, OpenAI, and n8n to create a workflow that works for you even when you're offline. Farzeen guides you through every step of the implementation: from setting up your development environment and building a robust Node.js/Express application, to implementing a sophisticated AI-driven log analysis system that automatically detects failures, generates patches, and opens pull requests on your behalf.</p>
<p>The course goes beyond the basics by showing you how to secure your automation using GitHub Secrets, configure N8N webhooks, and ensure your system is production-ready. You will also learn how to set up automated email notifications, keeping your entire team in the loop without you ever having to lift a finger.</p>
<p>By the end of this tutorial, you will have the knowledge to reduce downtime and spend more time building great features. Watch the full course on the <a href="https://youtu.be/vj68el9hRvU">freeCodeCamp.org YouTube channel</a> (1-hour watch).</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/vj68el9hRvU" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Set Up OpenID Connect (OIDC) in GitHub Actions for AWS
 ]]>
                </title>
                <description>
                    <![CDATA[ If you've been storing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as GitHub Secrets to deploy to AWS, you're not alone. It's the most common approach and it's also one of the biggest security risks i ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-set-up-openid-connect-oidc-in-github-actions-for-aws/</link>
                <guid isPermaLink="false">69ef7bbf330a1ad7f7f2d579</guid>
                
                    <category>
                        <![CDATA[ OpenID Connect ]]>
                    </category>
                
                    <category>
                        <![CDATA[ OIDC ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub Actions ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Tolani Akintayo ]]>
                </dc:creator>
                <pubDate>Mon, 27 Apr 2026 15:07:43 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/83b71e24-b63b-42a4-ac1c-d59e226da6c3.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've been storing <code>AWS_ACCESS_KEY_ID</code> and <code>AWS_SECRET_ACCESS_KEY</code> as GitHub Secrets to deploy to AWS, you're not alone. It's the most common approach and it's also one of the biggest security risks in a CI/CD pipeline.</p>
<p>Here's why: static credentials don't expire on their own. If they get leaked through a misconfigured workflow, a public fork, or a compromised repository, an attacker has persistent access to your AWS environment until you manually rotate them. And most teams don't rotate them often enough.</p>
<p>OpenID Connect (OIDC) solves this entirely. Instead of storing long-lived credentials, GitHub Actions requests a <strong>short-lived token</strong> directly from AWS every time your workflow runs. No secrets to rotate. No credentials to leak. No manual key management.</p>
<p>In this tutorial, you'll learn how to set up OIDC authentication between GitHub Actions and AWS from scratch. By the end, your workflows will authenticate to AWS securely without storing a single access key.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-openid-connect-oidc">What Is OpenID Connect (OIDC)?</a></p>
</li>
<li><p><a href="#heading-how-oidc-works-between-github-actions-and-aws">How OIDC Works Between GitHub Actions and AWS</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-step-1-create-an-iam-oidc-identity-provider-in-aws">Step 1: Create an IAM OIDC Identity Provider in AWS</a></p>
<p><a href="#heading-step-2-create-an-iam-role-with-a-trust-policy">Step 2: Create an IAM Role with a Trust Policy</a></p>
<p><a href="#heading-step-3-attach-permissions-to-the-iam-role">Step 3: Attach Permissions to the IAM Role</a></p>
<p><a href="#heading-step-4-store-the-role-arn-as-a-github-actions-variable">Step 4: Store the Role ARN as a GitHub Actions Variable</a></p>
<p><a href="#heading-step-5-configure-your-github-actions-workflow">Step 5: Configure Your GitHub Actions Workflow</a></p>
<p><a href="#heading-step-6-run-and-verify-your-workflow">Step 6: Run and Verify Your Workflow</a></p>
</li>
<li><p><a href="#heading-security-best-practices">Security Best Practices</a></p>
</li>
<li><p><a href="#heading-troubleshooting-common-errors">Troubleshooting Common Errors</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-references">References</a></p>
</li>
</ul>
<h2 id="heading-what-is-openid-connect-oidc">What Is OpenID Connect (OIDC)?</h2>
<p>OpenID Connect is an identity protocol built on top of OAuth 2.0. It allows systems to verify identity through tokens rather than shared secrets.</p>
<p>In the context of GitHub Actions and AWS:</p>
<ul>
<li><p><strong>GitHub</strong> acts as the <strong>identity provider (IdP)</strong>. It issues a signed JWT (JSON Web Token) for each workflow run.</p>
</li>
<li><p><strong>AWS</strong> acts as the <strong>service provider</strong>. It validates that token against GitHub's public keys and exchanges it for temporary AWS credentials. The credentials AWS returns are short-lived (valid for up to 1 hour by default) and scoped to exactly the IAM role you define. When the workflow ends, those credentials are gone.</p>
</li>
</ul>
<p>This model is called <strong>federated identity</strong>. It's the same concept used when you "Sign in with Google" on a third-party website. The difference is that instead of a user signing in, your workflow is the one authenticating.</p>
<h2 id="heading-how-oidc-works-between-github-actions-and-aws">How OIDC Works Between GitHub Actions and AWS</h2>
<p>Before writing a single line of YAML, it beneficial to understand the flow. This is my personal approach when implementing new technologies or concepts. Here's what happens every time your workflow runs:</p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/8b5b39de-f671-4ffe-a2db-96d10ade69b3.jpg" alt="Diagram showing the OIDC authentication flow between GitHub Actions and AWS" style="display: block;" width="449" height="544" loading="lazy">

<p>The diagram illustrates a secure authentication flow between GitHub Actions and AWS using OpenID Connect (OIDC), eliminating the need to store long-lived AWS credentials in GitHub. Here's what happens step-by-step:</p>
<p><strong>1. Initial Authentication Request</strong></p>
<p>When your GitHub Actions workflow starts, the runner (the virtual machine executing your workflow) requests a JSON Web Token (JWT) from GitHub's OIDC provider located at <code>https://token.actions.githubusercontent.com</code>.</p>
<p><strong>2. Token Issuance</strong></p>
<p>GitHub's OIDC provider generates and signs a JWT containing important claims (metadata) about your workflow. These claims include details like which repository the workflow is running from, which branch triggered it, what environment it's running in, and other contextual information that proves the workflow's identity.</p>
<p><strong>3. Token Validation</strong></p>
<p>The GitHub Actions runner presents this signed JWT to AWS Security Token Service (STS). AWS STS validates the JWT's signature by checking it against GitHub's publicly available cryptographic keys, ensuring the token is authentic and hasn't been tampered with.</p>
<p><strong>4. Trust Policy Verification</strong></p>
<p>AWS STS checks the trust policy configured on your IAM Role. This trust policy specifies which GitHub repositories, branches, or environments are allowed to assume this role. If the claims in the JWT match your trust policy conditions, authentication succeeds.</p>
<p><strong>5. Temporary Credentials Issued</strong></p>
<p>Once validated, AWS STS returns temporary security credentials to the GitHub Actions runner. These credentials include an Access Key ID, Secret Access Key, and Session Token that are valid for a limited time (typically 1 hour by default, configurable up to 12 hours).</p>
<p><strong>6. AWS API Access</strong></p>
<p>The GitHub Actions runner uses these temporary credentials to authenticate API calls to your AWS resources such as pushing Docker images to ECR, updating ECS services, writing to S3 buckets, or invoking Lambda functions.</p>
<p>The key point: <strong>AWS never sees your GitHub credentials, and GitHub never sees your AWS credentials.</strong> The JWT is the only thing exchanged and it's signed, scoped, and short-lived.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, make sure you have the following in place:</p>
<ul>
<li><p>An <strong>AWS account</strong> with IAM permissions to create identity providers and roles</p>
</li>
<li><p>A <strong>GitHub repository</strong> (public or private) where your workflows will run</p>
</li>
<li><p>Basic familiarity with <strong>GitHub Actions</strong>, knowing how to write a <code>.yml</code> workflow file</p>
</li>
<li><p>Basic familiarity with <strong>AWS IAM</strong> roles, policies, and permissions</p>
</li>
<li><p>The <strong>AWS CLI</strong> installed and configured (optional, but useful for verification). You don't need to be an AWS expert. Each step includes the exact console path and the configuration values you need.</p>
</li>
</ul>
<h2 id="heading-step-1-create-an-iam-oidc-identity-provider-in-aws">Step 1: Create an IAM OIDC Identity Provider in AWS</h2>
<p>The first thing you need to do is tell AWS to trust GitHub as an identity provider. This is a one-time setup per AWS account.</p>
<h3 id="heading-how-to-do-it-in-the-aws-console">How to Do It in the AWS Console</h3>
<p>1. Open the <a href="https://console.aws.amazon.com/iam/">AWS IAM Console</a></p>
<p>2. In the left sidebar, click Identity providers</p>
<p>3. Click Add provider</p>
<p>4. For Provider type, select OpenID Connect</p>
<p>5. For Provider URL, enter:</p>
<pre><code class="language-plaintext">https://token.actions.githubusercontent.com
</code></pre>
<p>6. For Audience, enter:</p>
<pre><code class="language-plaintext">sts.amazonaws.com
</code></pre>
<p>7. Click Add provider</p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/66f1de9d-36f9-462e-ad0c-090b152be6e5.png" alt="AWS IAM console showing the Add Identity Provider form configured for GitHub Actions OIDC" style="display: block;" width="1349" height="609" loading="lazy">

<h3 id="heading-how-to-do-it-with-the-aws-cli">How to Do It with the AWS CLI</h3>
<p>If you prefer the terminal, run this command:</p>
<pre><code class="language-shell">aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/4b779fa0-0df2-4bc3-bbf4-9839ef8ce5e6.png" alt="terminal-oidc-connect-created" style="display: block;" width="966" height="114" loading="lazy">

<p>Once created, you'll see <code>token.actions.githubusercontent.com</code> listed under <strong>Identity providers</strong> in your IAM console. This provider will be referenced in your IAM role's trust policy in the next step.</p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/eb820487-6553-43d2-b6b7-4e7b08d039ef.png" alt="verify oidc connect in AWS" style="display: block;" width="1132" height="284" loading="lazy">

<h2 id="heading-step-2-create-an-iam-role-with-a-trust-policy">Step 2: Create an IAM Role with a Trust Policy</h2>
<p>Now you need an IAM role that your GitHub Actions workflow will assume. The trust policy on this role controls which repositories and branches are allowed to request credentials.</p>
<h3 id="heading-how-to-create-the-iam-role-in-the-aws-console">How to Create the IAM Role in the AWS Console</h3>
<p>1. Open the <a href="https://console.aws.amazon.com/iam/">AWS IAM Console</a></p>
<p>2. In the left sidebar, click <strong>Roles</strong></p>
<p>3. Click <strong>Create role</strong></p>
<p>4. For <strong>Trusted entity type</strong>, select <strong>Web identity</strong></p>
<p>5. For <strong>Identity Provider</strong>, choose: <code>token.actions.githubusercontent.com</code> which you created earlier.</p>
<p>6. For Audience, choose <code>sts.amazonaws.com</code> as well</p>
<p>7. For GitHub organisation, enter your GitHub username or organization name</p>
<p>8. For GitHub repository, enter your GitHub repository</p>
<p>9. For GitHub branch, enter your branch name (for example, main)</p>
<p>10. Click Next, then Next, give a name to the role and click create role</p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/dca12969-db8a-4ec4-885e-e953f4808f6c.png" alt="create-iam-role-for-github-action-via-the-console" style="display: block;" width="1351" height="620" loading="lazy">

<p>Note: Creating the IAM role using this approach already establishes the <strong>Trusted Entities</strong> using a trusted policy based on the step 4-9 above. You can verify this by clicking on the created role and navigating to Trust relationships.</p>
<h3 id="heading-how-to-create-the-iam-role-with-the-aws-cli">How to Create the IAM Role with the AWS CLI</h3>
<p>First, you'll need to create a trust policy document on your local machine: You can call it <code>trust-policy.json</code>:</p>
<pre><code class="language-json">{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:YOUR_GITHUB_ORG/YOUR_REPO_NAME:*"
        }
      }
    }
  ]
}
</code></pre>
<p>Replace the following placeholders before saving:</p>
<table>
<thead>
<tr>
<th>Placeholder</th>
<th>Replace With</th>
</tr>
</thead>
<tbody><tr>
<td><code>YOUR_ACCOUNT_ID</code></td>
<td>Your 12-digit AWS account ID</td>
</tr>
<tr>
<td><code>YOUR_GITHUB_ORG</code></td>
<td>Your GitHub username or organization name</td>
</tr>
<tr>
<td><code>YOUR_REPO_NAME</code></td>
<td>The name of your GitHub repository</td>
</tr>
</tbody></table>
<h3 id="heading-how-to-understand-the-sub-condition">How to Understand the <code>sub</code> Condition</h3>
<p>The <code>sub (subject)</code> claim in the JWT tells AWS exactly where the request is coming from. The value <code>repo:your-org/your-repo:*</code> means any branch in that repository can assume this role.</p>
<p>You can tighten this further depending on your needs:</p>
<pre><code class="language-shell"># Only the main branch
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
 
# Only a specific GitHub Environment
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:environment:production"
</code></pre>
<p>Scoping this correctly is one of the most important security decisions in this setup. Here's how to decide:</p>
<ul>
<li><p>Use <code>ref:refs/heads/main</code> if only your main/production branch should deploy to AWS. This is the most restrictive and secure option: feature branches can't accidentally (or maliciously) trigger deployments or modify production resources.</p>
</li>
<li><p>Use <code>environment:production</code> if you're using GitHub Environments with protection rules (required reviewers, deployment gates). This lets you control deployments through GitHub's approval workflow while still restricting which workflows can access AWS.</p>
</li>
<li><p>Use <code>repo:your-org/your-repo:*</code> (wildcard) only if you need any branch to deploy. for example, in development environments where every feature branch deploys to its own isolated stack. Never use this for production roles.</p>
</li>
</ul>
<p>Run this command to create the role using your trust policy:</p>
<pre><code class="language-shell">aws iam create-role \
  --role-name GitHubActionsOIDCRole \
  --assume-role-policy-document file://trust-policy.json \
  --description "Role assumed by GitHub Actions via OIDC"
</code></pre>
<p>Take note of the <strong>Role ARN</strong> in the output. It will look like this:</p>
<pre><code class="language-plaintext">arn:aws:iam::YOUR_ACCOUNT_ID:role/GitHubActionsOIDCRole
</code></pre>
<p>You'll need this ARN in your workflow YAML in Step 4.</p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/6bb154e7-0fb3-4c58-94e1-90116eaea95a.png" alt="terminal output of the AWS CLI create-role command showing the returned Role ARN" style="display: block;" width="1123" height="615" loading="lazy">

<h2 id="heading-step-3-attach-permissions-to-the-iam-role">Step 3: Attach Permissions to the IAM Role</h2>
<p>The IAM role can now authenticate, but it has no permissions yet. You need to attach a policy that defines what your workflow is actually allowed to do in AWS.</p>
<h3 id="heading-how-to-apply-the-principle-of-least-privilege">How to Apply the Principle of Least Privilege</h3>
<p>Only grant the permissions your workflow genuinely needs. If your workflow deploys to S3, give it S3 permissions. If it pushes images to ECR, give it ECR permissions. Never attach <code>AdministratorAccess</code> to a CI/CD role.</p>
<h4 id="heading-option-1-attach-an-aws-managed-policy-quick-start">Option 1: Attach an AWS managed policy (quick start):</h4>
<pre><code class="language-shell">aws iam attach-role-policy \
  --role-name GitHubActionsOIDCRole \
  --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess
</code></pre>
<h4 id="heading-option-2-create-a-custom-policy-scoped-to-a-specific-s3-bucket-recommended-for-production">Option 2: Create a custom policy scoped to a specific S3 bucket (recommended for production):</h4>
<p>This approach is recommended for production because it limits the blast radius of a security incident. If your workflow credentials are ever compromised, a custom policy scoped to a specific bucket means an attacker can only affect that single bucket not every S3 bucket in your AWS account. It also prevents accidental misconfigurations in your workflow from impacting unrelated resources.</p>
<p>Create a file called <code>s3-deploy-policy.json</code>:</p>
<pre><code class="language-json">{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:DeleteObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::your-bucket-name",
        "arn:aws:s3:::your-bucket-name/*"
      ]
    }
  ]
}
</code></pre>
<p>Then create and attach it:</p>
<pre><code class="language-shell">aws iam create-policy \
  --policy-name GitHubActionsS3DeployPolicy \
  --policy-document file://s3-deploy-policy.json
 
aws iam attach-role-policy \
  --role-name GitHubActionsOIDCRole \
  --policy-arn arn:aws:iam::YOUR_ACCOUNT_ID:policy/GitHubActionsS3DeployPolicy
</code></pre>
<p>Note: You can as well implement <strong>Step 3</strong> via the console.</p>
<p><strong>Reference:</strong> For a full list of available AWS IAM actions, see the <a href="https://docs.aws.amazon.com/service-authorization/latest/reference/reference_policies_actions-resources-contextkeys.html">AWS IAM actions reference</a>.</p>
<h2 id="heading-step-4-store-the-role-arn-as-a-github-actions-variable">Step 4: Store the Role ARN as a GitHub Actions Variable</h2>
<p>Before you configure your workflow, you need to make the Role ARN available to it. You'll store it as a repository variable in GitHub, not a secret, because the ARN itself isn't sensitive data.</p>
<h3 id="heading-how-to-add-the-variable-in-your-repository">How to Add the Variable in Your Repository</h3>
<p>First, open your GitHub repository and click <strong>Settings:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/b2dd526a-00ca-44eb-8d22-b78dfd220a14.png" alt="GitHub repository top navigation bar with the Settings tab highlighted" style="display: block;" width="1310" height="307" loading="lazy">

<p>In the left sidebar, scroll down to <strong>Secrets and variables</strong>, then click <strong>Actions:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/61d67c83-7bbc-4570-93ec-f2ee4207ad6e.png" alt="GitHub repository settings sidebar showing Secrets and variables expanded with Actions selected" style="display: block;" width="1266" height="325" loading="lazy">

<p>Then click the <strong>Variables</strong> tab (not Secrets). Click New repository variable – you can set the <strong>Name</strong> to:</p>
<pre><code class="language-plaintext">AWS_ROLE_ARN
</code></pre>
<p>Set the <strong>Value</strong> to your Role ARN from Step 2, for example:</p>
<pre><code class="language-plaintext">arn:aws:iam::YOUR_ACCOUNT_ID::role/GitHubActionsOIDCRole
</code></pre>
<p>Click <strong>Add variable:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/71f5468d-d4ab-45c1-aecd-8509f575237a.png" alt="GitHub repository Actions variables tab showing AWS_ROLE_ARN variable added successfully" style="display: block;" width="1083" height="377" loading="lazy">

<p>You'll reference this variable in your workflow in the next step using <code>${{</code> <code>vars.AWS_ROLE_ARN }}</code>.</p>
<h2 id="heading-step-5-configure-your-github-actions-workflow">Step 5: Configure Your GitHub Actions Workflow</h2>
<p>With AWS and GitHub fully configured, you now need to update your workflow to request an OIDC token and use it to authenticate.</p>
<h3 id="heading-how-to-set-the-required-workflow-permissions">How to Set the Required Workflow Permissions</h3>
<p>Your workflow <strong>must</strong> declare <code>id-token: write</code>. Without this, GitHub won't issue an OIDC token to the runner.</p>
<pre><code class="language-yaml">permissions:
  id-token: write   # Required to request the OIDC JWT
  contents: read    # Required to checkout the repository
</code></pre>
<p><strong>Important:</strong> If you set permissions at the job level, they override any top-level permissions. Make sure <code>id-token: write</code> is present at whichever level your AWS authentication step runs.</p>
<h3 id="heading-full-workflow-example">Full Workflow Example</h3>
<p>Here's a complete workflow that authenticates to AWS using OIDC and deploys a static site to S3:</p>
<pre><code class="language-yaml">name: Deploy to AWS S3
 
on:
  push:
    branches:
      - main
 
permissions:
  id-token: write
  contents: read
 
jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
          aws-region: us-east-2
 
      - name: Verify AWS identity
        run: aws sts get-caller-identity
 
      - name: Deploy to S3
        run: |
          aws s3 sync ./code s3://your-bucket-name
</code></pre>
<p>Replace the following before committing:</p>
<table>
<thead>
<tr>
<th>Placeholder</th>
<th>Replace With</th>
</tr>
</thead>
<tbody><tr>
<td><code>AWS_ROLE_ARN</code></td>
<td>The variable name for your IAM role ARN in GitHub</td>
</tr>
<tr>
<td><code>us-east-2</code></td>
<td>Your target AWS region</td>
</tr>
<tr>
<td><code>your-bucket-name</code></td>
<td>Your S3 bucket name</td>
</tr>
<tr>
<td><code>./code</code></td>
<td>The local directory where the file you want to sync to S3 is located</td>
</tr>
</tbody></table>
<p>You can see the code sample in my GitHub Repo <a href="https://github.com/tolani-akintayo/OpenID-Connect-in-GitHub-Actions-for-AWS">here</a>.</p>
<p><strong>Note:</strong> The <code>aws-actions/configure-aws-credentials</code> action handles the entire OIDC token exchange automatically. It requests the JWT from GitHub, calls <code>sts:AssumeRoleWithWebIdentity</code>, and exports the temporary credentials as environment variables for the rest of the job.</p>
<p>See the <a href="https://github.com/aws-actions/configure-aws-credentials">action's official documentation</a> for all available options.</p>
<h2 id="heading-step-6-run-and-verify-your-workflow">Step 6: Run and Verify Your Workflow</h2>
<p>Push your workflow to the <code>main</code> branch and open the <strong>Actions</strong> tab in your repository to watch it run.</p>
<h3 id="heading-what-a-successful-run-looks-like">What a Successful Run Looks Like</h3>
<p>The Configure AWS credentials via OIDC step should show:</p>
<pre><code class="language-plaintext">Assuming role with OIDC: arn:aws:iam::YOUR_ACCOUNT_ID:role/GitHubActionsOIDCRole
</code></pre>
<p>The Verify AWS identity step (<code>aws sts get-caller-identity</code>) should return:</p>
<pre><code class="language-json">{
    "UserId": "AROA...:GitHubActions",
    "Account": "YOUR_ACCOUNT_ID",
    "Arn": "arn:aws:sts::YOUR_ACCOUNT_ID:assumed-role/GitHubActionsOIDCRole/GitHubActions"
}
</code></pre>
<p>If you see an <code>assumed-role</code> ARN in the output, OIDC is working correctly. Your workflow is now authenticating to AWS without a single stored credential.</p>
<h2 id="heading-security-best-practices">Security Best Practices</h2>
<p>Getting OIDC working is step one. Locking it down properly is step two.</p>
<h3 id="heading-scope-the-sub-condition-as-tightly-as-possible">Scope the <code>sub</code> Condition as Tightly as Possible</h3>
<p>Don't use a wildcard like <code>repo:your-org/*:*</code> that allows any repository in your organization to assume the role. Scope it to the exact repository and branch that needs access.</p>
<pre><code class="language-json">"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
</code></pre>
<h3 id="heading-use-github-environments-for-production-deployments">Use GitHub Environments for Production Deployments</h3>
<p>GitHub Environments let you add manual approval gates and restrict which branches can deploy. When combined with OIDC, you can scope your trust policy to only allow the <code>production</code> environment:</p>
<pre><code class="language-json">"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:environment:production"
</code></pre>
<h3 id="heading-apply-least-privilege-permissions-to-every-iam-role">Apply Least-Privilege Permissions to Every IAM Role</h3>
<p>Never attach <code>AdministratorAccess</code> or <code>PowerUserAccess</code> to a role used by CI/CD. Define a custom policy with only the actions your workflow actually needs.</p>
<h3 id="heading-create-separate-iam-roles-per-environment">Create Separate IAM Roles Per Environment</h3>
<p>A staging role and a production role should have different permission scopes. Your staging deployment role should never have write access to production resources.</p>
<h3 id="heading-enable-aws-cloudtrail">Enable AWS CloudTrail</h3>
<p>Every call made using the temporary credentials is logged in CloudTrail under the assumed role ARN. This gives you a full audit trail of exactly what your workflow did in AWS.</p>
<p><strong>Reference:</strong> GitHub's official security hardening guide for OIDC: <a href="https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect">About security hardening with OpenID Connect</a></p>
<h2 id="heading-troubleshooting-common-errors">Troubleshooting Common Errors</h2>
<h3 id="heading-error-not-authorized-to-perform-stsassumerolewithwebidentity">Error: <code>Not authorized to perform sts:AssumeRoleWithWebIdentity</code></h3>
<p>This usually means the trust policy on your IAM role doesn't match the <code>sub</code> claim in the JWT.</p>
<p>Check the following:</p>
<ul>
<li><p>The <code>sub</code> condition exactly matches your repository path (it is case-sensitive)</p>
</li>
<li><p>The <code>aud</code> condition is set to <code>sts.amazonaws.com</code></p>
</li>
<li><p>The <code>Federated</code> principal uses the correct AWS account ID</p>
</li>
</ul>
<p>To inspect the actual token claims your workflow is receiving, add this debug step temporarily:</p>
<pre><code class="language-yaml">- name: Print OIDC token claims
  run: |
    TOKEN=\((curl -s -H "Authorization: Bearer \)ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
      "$ACTIONS_ID_TOKEN_REQUEST_URL&amp;audience=sts.amazonaws.com" | jq -r '.value')
    echo $TOKEN | cut -d '.' -f2 | base64 -d 2&gt;/dev/null | jq .
</code></pre>
<h3 id="heading-error-could-not-load-credentials-from-any-providers">Error: <code>Could not load credentials from any providers</code></h3>
<p>This almost always means <code>id-token: write</code> is missing from your workflow permissions. Double-check that you have:</p>
<pre><code class="language-yaml">permissions:
  id-token: write
  contents: read
</code></pre>
<h3 id="heading-error-accessdenied-when-calling-an-aws-service">Error: <code>AccessDenied</code> When Calling an AWS Service</h3>
<p>Authentication succeeded but the IAM role doesn't have permission to perform the action your workflow is attempting. Check the permissions policy attached to your role and compare it against the specific action in the error message.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You've gone from storing static, long-lived AWS credentials in GitHub Secrets to a fully keyless authentication setup using OIDC. Here's what you accomplished:</p>
<ul>
<li><p>Registered GitHub as a trusted OIDC identity provider in AWS.</p>
</li>
<li><p>Created an IAM role with a scoped trust policy tied to a specific repository.</p>
</li>
<li><p>Attached least-privilege permissions to that role.</p>
</li>
<li><p>Configured your GitHub Actions workflow to request and use short-lived AWS credentials.</p>
</li>
<li><p>Verified the authentication flow end-to-end.</p>
</li>
</ul>
<p>This pattern works across every AWS service from S3, ECS, Lambda, ECR, Secrets Manager, and more. The workflow example here uses S3, but you only need to swap out the permissions policy and the deployment commands to adapt it for any service.</p>
<p>If you want to go further, explore:</p>
<ul>
<li><p><a href="https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#supported-cloud-providers">Configuring OIDC for multiple cloud providers</a>: Azure, GCP, and HashiCorp Vault.</p>
</li>
<li><p><a href="https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment">GitHub Environments and deployment protection rules</a>: for multi-stage pipelines with approval gates.</p>
</li>
<li><p><a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html">AWS IAM Access Analyzer</a>: to validate and tighten your role policies automatically.</p>
</li>
</ul>
<p><em>If you're building out your DevOps practice and want a complete, production-ready reference for infrastructure automation, CI/CD, and platform engineering, check out</em> <a href="https://coachli.co/tolani-akintayo/PR-H4oQS"><em><strong>The Startup DevOps Field Guide</strong></em></a><em>. It covers the patterns, templates, and runbooks I've used across real AWS environments.</em></p>
<p><em>You can also connect with me on</em> <a href="https://www.linkedin.com/in/tolani-akintayo"><em>LinkedIn</em></a></p>
<h2 id="heading-references">References</h2>
<ul>
<li><p><a href="https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect">GitHub Docs: About security hardening with OpenID Connect</a></p>
</li>
<li><p><a href="https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services">GitHub Docs: Configuring OpenID Connect in Amazon Web Services</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html">AWS Docs: Creating OpenID Connect (OIDC) identity providers</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html">AWS Docs: AssumeRoleWithWebIdentity API Reference</a></p>
</li>
<li><p><a href="https://github.com/aws-actions/configure-aws-credentials">aws-actions/configure-aws-credentials - GitHub</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/service-authorization/latest/reference/reference_policies_actions-resources-contextkeys.html">AWS IAM Actions Reference</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-user-guide.html">AWS CloudTrail User Guide</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How I Built a Production-Ready CI/CD Pipeline for a Monorepo-Based Microservices System with Jenkins, Docker Compose, and Traefik ]]>
                </title>
                <description>
                    <![CDATA[ This tutorial is a complete, real-world guide to building a production-ready CI/CD pipeline using Jenkins, Docker Compose, and Traefik on a single Linux server. You’ll learn how to expose services on  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-production-ready-ci-cd-pipeline-for-monorepo-based-microservices-system/</link>
                <guid isPermaLink="false">69ea60c8904b915438a58ca2</guid>
                
                    <category>
                        <![CDATA[ Jenkins ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Traefik ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Md Tarikul Islam ]]>
                </dc:creator>
                <pubDate>Thu, 23 Apr 2026 18:11:20 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/66cb39fcaa2a09f9a8d691c1/d59c62f5-e376-4f09-851f-83e437f9960a.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>This tutorial is a complete, real-world guide to building a production-ready CI/CD pipeline using Jenkins, Docker Compose, and Traefik on a single Linux server.</p>
<p>You’ll learn how to expose services on a custom domain with auto-renewing HTTPS, and implement a smart deployment strategy that detects changes and redeploys only the affected microservices. This helps avoid unnecessary full-stack redeploys. We'll also cover real production issues and the exact fixes for each one.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-1-what-youll-build">1. What you'll build</a></p>
</li>
<li><p><a href="#heading-2-architecture">2. Architecture</a></p>
</li>
<li><p><a href="#heading-3-server-prerequisites">3. Server prerequisites</a></p>
</li>
<li><p><a href="#heading-4-traefik-the-reverse-proxy">4. Traefik — the reverse proxy</a></p>
</li>
<li><p><a href="#heading-5-run-jenkins-in-docker">5. Run Jenkins in Docker</a></p>
</li>
<li><p><a href="#heading-6-expose-jenkins-on-a-domain-via-traefik">6. Expose Jenkins on a domain via Traefik</a></p>
</li>
<li><p><a href="#heading-7-first-time-jenkins-setup">7. First-time Jenkins setup</a></p>
</li>
<li><p><a href="#heading-8-add-the-github-credential">8. Add the GitHub credential</a></p>
</li>
<li><p><a href="#heading-9-create-the-pipeline-job">9. Create the pipeline job</a></p>
</li>
<li><p><a href="#heading-10-the-jenkinsfile-deploy-only-what-changed">10. The Jenkinsfile (deploy only what changed)</a></p>
</li>
<li><p><a href="#heading-11-end-to-end-test">11. End-to-end test</a></p>
</li>
<li><p><a href="#heading-12-troubleshooting-every-error-we-hit">12. Troubleshooting — every error we hit</a></p>
</li>
<li><p><a href="#heading-13-mental-model-host-vs-container">13. Mental model: host vs. container</a></p>
</li>
<li><p><a href="#heading-14-daily-operations-cheat-sheet">14. Daily operations cheat sheet</a></p>
</li>
<li><p><a href="#heading-15-what-id-do-differently-next-time">15. What I'd do differently next time</a></p>
</li>
<li><p><a href="#heading-closing-thoughts">Closing thoughts</a></p>
</li>
</ul>
<h2 id="heading-1-what-youll-build">1. What You'll Build</h2>
<p>In this tutorial, you'll build a Jenkins instance running inside Docker on the same Linux server as your application stack.</p>
<p>Traefik will act as a reverse proxy in front of Jenkins, exposing it via a clean URL (<a href="https://jenkins.example.com"><code>https://jenkins.example.com</code></a>) with <strong>auto-renewing Let's Encrypt certificates</strong>.</p>
<p>You'll also create a Jenkinsfile in your application repository that:</p>
<ul>
<li><p>Automatically triggers on every push to the <code>staging</code> branch,</p>
</li>
<li><p>Detects which microservices changed in each commit,</p>
</li>
<li><p>Pulls the latest code on the host machine,</p>
</li>
<li><p>Rebuilds and restarts <strong>only the affected services</strong>.</p>
</li>
</ul>
<p>On every push, only the relevant services are redeployed.</p>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p>Before jumping in, this guide assumes you’re already comfortable with a few core concepts and tools.</p>
<p>This isn't a beginner-level tutorial — we’ll be working directly with infrastructure, containers, and CI/CD pipelines.</p>
<p>You should be familiar with:</p>
<ul>
<li><p>Basic Linux commands (SSH, file system navigation, permissions)</p>
</li>
<li><p>Docker fundamentals (images, containers, volumes, networks)</p>
</li>
<li><p>Git workflows (clone, pull, branches)</p>
</li>
<li><p>General idea of CI/CD pipelines</p>
</li>
</ul>
<p>Tools and environment required:</p>
<ul>
<li><p>A Linux server (Ubuntu recommended)</p>
</li>
<li><p>Docker Engine + Docker Compose (v2)</p>
</li>
<li><p>A domain name (for Traefik + HTTPS)</p>
</li>
<li><p>GitHub repository (for your backend project)</p>
</li>
<li><p>Basic understanding of microservices architecture</p>
</li>
</ul>
<p>If you’re comfortable with the above, you’re ready to follow along.</p>
<h2 id="heading-2-architecture">2. Architecture</h2>
<p>Here's an overview of the architecture:</p>
<pre><code class="language-plaintext">┌──────────────────────────── Linux server (Ubuntu) ────────────────────────────┐
│                                                                               │
│   /home/developer/projects/                                                  │
│       └── project-prod-configs/             ← infra repo (compose, Traefik) │
│              ├── docker-compose.staging.yml                                   │
│              ├── traefik.staging.yml                                          │
│              └── project-backend/          ← app repo (services, gateways) │
│                     ├── Jenkinsfile                                           │
│                     ├── docker-compose.staging.yml                            │
│                     └── apps/                                                 │
│                            ├── services/&lt;name&gt;/                               │
│                            ├── gateways/&lt;name&gt;/                               │
│                            └── core/&lt;name&gt;/                                   │
│                                                                               │
│   ┌─────────────────────── Docker network: proxy ──────────────────────┐      │
│   │  traefik (80, 443)                                                 │      │
│   │     │                                                              │      │
│   │     ├──► jenkins  (projects-jenkins-staging)                     │      │
│   │     │      ↳ /projects  ← bind-mount of the host project tree     │      │
│   │     │      ↳ /var/run/docker.sock ← controls host Docker           │      │
│   │     │                                                              │      │
│   │     └──► your services &amp; gateways (built by the pipeline)          │      │
│   └────────────────────────────────────────────────────────────────────┘      │
│                                                                               │
└───────────────────────────────────────────────────────────────────────────────┘
            ▲
            │  webhook on push
            │
   GitHub: &lt;org&gt;/project-backend (branch: staging)
</code></pre>
<p>There are two key ideas here:</p>
<ol>
<li><p><strong>Jenkins runs in a container</strong>, but it controls the <strong>host's</strong> Docker by mounting <code>/var/run/docker.sock</code>. It also bind-mounts the project folder as <code>/projects/...</code>, so it can <code>cd</code> into the real code on the host and run <code>docker compose</code> there.</p>
</li>
<li><p>The <strong>Jenkinsfile lives inside the app repo</strong>, so the pipeline definition is versioned with the code. Jenkins simply points at it.</p>
</li>
</ol>
<h3 id="heading-3-server-prerequisites">3. Server Prerequisites</h3>
<p>Before we start configuring Jenkins or Traefik, we need to prepare the server properly.</p>
<p>In this step, we’ll:</p>
<ul>
<li><p>Create a dedicated Linux user for managing the project</p>
</li>
<li><p>Install Docker and Docker Compose</p>
</li>
<li><p>Set up the folder structure for our repositories</p>
</li>
</ul>
<p>This ensures our CI/CD pipeline runs in a clean and predictable environment.</p>
<pre><code class="language-bash"># Linux user that owns the project tree
sudo adduser developer

# Docker engine + Compose plugin
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker developer

# Sanity check Compose v2
docker compose version
# -&gt; Docker Compose version v2.x.y

# Find where the Compose plugin binary lives — write it down, you'll need it
ls /usr/libexec/docker/cli-plugins/docker-compose
# (some distros use /usr/lib/docker/cli-plugins/docker-compose)

# Project layout
sudo mkdir -p /home/developer/project
sudo chown -R developer:developer /home/developer/project

# Clone both repos in the right place
cd /home/developer/projects
git clone https://github.com/&lt;org&gt;/projects-prod-configs.git
cd projects-prod-configs
git clone -b staging https://github.com/&lt;org&gt;/projects-backend.git
</code></pre>
<p>You should now have:</p>
<pre><code class="language-plaintext">/home/developer/projects/projects-prod-configs/projects-backend
</code></pre>
<p>Memorize this path — your Jenkinsfile references it.</p>
<h3 id="heading-dns">DNS</h3>
<p>Point an A-record for your Jenkins subdomain to the server's public IP <strong>before</strong> the next steps so Let's Encrypt can validate via HTTP challenge:</p>
<pre><code class="language-plaintext">jenkins.example.com   A   &lt;server-public-ip&gt;
</code></pre>
<h2 id="heading-4-traefik-the-reverse-proxy">4. Traefik — the Reverse Proxy</h2>
<p>Traefik acts as the entry point to your entire system. Instead of exposing each service manually with ports, Traefik automatically:</p>
<ul>
<li><p>Routes traffic based on domain names</p>
</li>
<li><p>Generates and renews HTTPS certificates using Let’s Encrypt</p>
</li>
<li><p>Connects to Docker and detects services dynamically</p>
</li>
</ul>
<p>In simple terms, Traefik lets you access services like:</p>
<p><a href="https://jenkins.example.com">https://jenkins.example.com</a><br><a href="https://api.example.com">https://api.example.com</a></p>
<p>…without manually configuring NGINX or managing SSL certificates.</p>
<p>In this setup, Traefik watches Docker containers and routes traffic using labels we'll define later.</p>
<p>Traefik gives every container a real domain and a real cert with <strong>zero per-service config</strong> — you just add a few labels.</p>
<h3 id="heading-traefikstagingyml-static-config"><code>traefik.staging.yml</code> (static config)</h3>
<p>Put this at the root of your infra repo:</p>
<pre><code class="language-yaml">api:
  dashboard: true

entryPoints:
  web:
    address: ":80"
  websecure:
    address: ":443"

certificatesResolvers:
  letsencrypt:
    acme:
      httpChallenge:
        entryPoint: web
      email: admin@example.com           # ← change me
      storage: /etc/traefik/acme.json

providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false              # only containers with traefik.enable=true
    network: proxy
  file:
    directory: /etc/traefik/dynamic
    watch: true

log:
  level: INFO

accessLog: {}
</code></pre>
<h3 id="heading-the-traefik-service-in-docker-composestagingyml">The Traefik service in <code>docker-compose.staging.yml</code></h3>
<pre><code class="language-yaml">networks:
  proxy:
    name: proxy
    driver: bridge
  internal:
    name: internal
    driver: bridge

volumes:
  acme-data:
  traefik-logs:
  jenkins-data:

services:
  traefik:
    image: traefik:v2.11
    container_name: projects-traefik-staging
    restart: unless-stopped
    ports:
      - "80:80"        # HTTP (auto-redirects to HTTPS)
      - "443:443"      # HTTPS
      - "8080:8080"    # Traefik dashboard (internal only — protect via firewall)
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik.staging.yml:/etc/traefik/traefik.yml:ro
      - ./dynamic:/etc/traefik/dynamic:ro
      - acme-data:/etc/traefik           # persists Let's Encrypt certs
      - traefik-logs:/var/log/traefik
    networks:
      - proxy
    command:
      - '--api.insecure=false'
      - '--api.dashboard=true'
      - '--providers.docker=true'
      - '--providers.docker.exposedbydefault=false'
      - '--providers.docker.network=proxy'
      - '--entrypoints.web.address=:80'
      - '--entrypoints.websecure.address=:443'
      - '--entrypoints.web.http.redirections.entryPoint.to=websecure'
      - '--entrypoints.web.http.redirections.entryPoint.scheme=https'
      - '--certificatesresolvers.letsencrypt.acme.httpchallenge=true'
      - '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web'
      - '--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:-admin@example.com}'
      - '--certificatesresolvers.letsencrypt.acme.storage=/etc/traefik/acme.json'
      - '--log.level=INFO'
      - '--accesslog=true'
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      # Traefik's own dashboard
      - "traefik.http.routers.traefik-dash.rule=Host(`traefik.example.com`)"
      - "traefik.http.routers.traefik-dash.entrypoints=websecure"
      - "traefik.http.routers.traefik-dash.tls.certresolver=letsencrypt"
      - "traefik.http.routers.traefik-dash.service=api@internal"
</code></pre>
<p>Bring it up:</p>
<pre><code class="language-bash">cd /home/developer/projects/projects-prod-configs
docker compose -f docker-compose.staging.yml up -d traefik
</code></pre>
<p>Watch the logs the first time — Traefik will request a cert for the dashboard host as soon as DNS resolves.</p>
<pre><code class="language-bash">docker logs -f projects-traefik-staging
</code></pre>
<p><strong>Tip.</strong> While testing, switch ACME to staging endpoint (<code>acme.caServer=https://acme-staging-v02.api.letsencrypt.org/directory</code>) so you don't burn through Let's Encrypt's rate limits if you misconfigure DNS. Remove that flag before going live.</p>
<h2 id="heading-5-run-jenkins-in-docker">5. Run Jenkins in Docker</h2>
<p>Add this Jenkins service to the same <code>docker-compose.staging.yml</code>. Every line matters (and the comments explain why).</p>
<pre><code class="language-yaml">  jenkins:
    image: jenkins/jenkins:lts
    container_name: projects-jenkins-staging
    restart: unless-stopped
    user: root                           # to use host docker.sock without UID juggling
    environment:
      - JAVA_OPTS=-Xmx1g -Xms512m -Duser.timezone=Asia/Dhaka
      - TZ=Asia/Dhaka                    # OS-level timezone inside container
      - JENKINS_OPTS=--prefix=/
    ports:
      - "3095:8080"                      # web UI (also reachable directly if needed)
      - "50000:50000"                    # inbound agent port
    volumes:
      - jenkins-data:/var/jenkins_home   # Jenkins config/jobs/secrets persistence
      - /var/run/docker.sock:/var/run/docker.sock                          # control host Docker
      - /usr/bin/docker:/usr/bin/docker                                     # docker CLI from host
      - /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins:ro  # docker compose plugin
      - /home/developer/projects:/projects                                # project tree
      - /etc/localtime:/etc/localtime:ro                                    # match host clock
      - /etc/timezone:/etc/timezone:ro
    networks:
      - proxy
      - internal
    healthcheck:
      test: ['CMD', 'curl', '-f', 'http://localhost:8080/login']
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 120s
    deploy:
      resources:
        limits:
          memory: 1024M
</code></pre>
<p><strong>Why</strong> <code>user: root</code><strong>?</strong> It's the simplest way to share <code>docker.sock</code> and the project bind-mount without UID/GID gymnastics. If you prefer an unprivileged user, you'll need to set <code>group: docker</code> and align UIDs/perms on host folders — possible but out of scope here.</p>
<h2 id="heading-6-expose-jenkins-on-a-domain-via-traefik">6. Expose Jenkins on a Domain via Traefik</h2>
<p>This is the section many guides skip. We'll add <strong>labels</strong> to the Jenkins service so Traefik picks it up automatically. No editing of Traefik config required.</p>
<pre><code class="language-yaml">  jenkins:
    # ... everything above ...
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"

      # 1) Router — match incoming Host
      - "traefik.http.routers.jenkins.rule=Host(`jenkins.example.com`)"
      - "traefik.http.routers.jenkins.entrypoints=websecure"
      - "traefik.http.routers.jenkins.tls.certresolver=letsencrypt"
      - "traefik.http.routers.jenkins.service=jenkins"

      # 2) Service — tell Traefik which container port is the app
      - "traefik.http.services.jenkins.loadbalancer.server.port=8080"

      # 3) Middleware — Jenkins needs X-Forwarded-Proto so it knows it's behind HTTPS
      - "traefik.http.middlewares.jenkins-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
      - "traefik.http.routers.jenkins.middlewares=jenkins-headers"
</code></pre>
<p>What each line does:</p>
<table>
<thead>
<tr>
<th>Label</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>traefik.enable=true</code></td>
<td>Opts this container in (we set <code>exposedByDefault=false</code>).</td>
</tr>
<tr>
<td><code>traefik.docker.network=proxy</code></td>
<td>Tells Traefik which network to talk to Jenkins on (Jenkins is on both <code>proxy</code> and <code>internal</code>).</td>
</tr>
<tr>
<td><code>routers.jenkins.rule=Host(...)</code></td>
<td>Forwards only this hostname to Jenkins.</td>
</tr>
<tr>
<td><code>routers.jenkins.entrypoints=websecure</code></td>
<td>Listens only on 443. (HTTP redirect was set up in section 4.)</td>
</tr>
<tr>
<td><code>routers.jenkins.tls.certresolver=letsencrypt</code></td>
<td>Auto-issues + renews the cert.</td>
</tr>
<tr>
<td><code>services.jenkins.loadbalancer.server.port=8080</code></td>
<td>Jenkins listens on 8080 inside the container.</td>
</tr>
<tr>
<td><code>customrequestheaders.X-Forwarded-Proto=https</code></td>
<td>Without this, Jenkins generates <code>http://</code> URLs in webhooks/links and breaks.</td>
</tr>
</tbody></table>
<p>Bring Jenkins up:</p>
<pre><code class="language-bash">cd /home/developer/projects/projects-prod-configs
docker compose -f docker-compose.staging.yml up -d jenkins

# Watch Traefik issue the certificate
docker logs -f projects-traefik-staging | grep -i acme
</code></pre>
<p>After 10–60 seconds you should be able to open <code>https://jenkins.example.com</code> and see Jenkins's setup wizard with a valid lock icon.</p>
<p>Inside Jenkins (after first login):</p>
<p>Manage Jenkins → System → Jenkins URL → set this to: <a href="https://jenkins.example.com/">https://jenkins.example.com/</a></p>
<p>This is important because Jenkins uses this base URL to generate:</p>
<ul>
<li><p>Webhook endpoints (for GitHub triggers)</p>
</li>
<li><p>Links inside emails and build logs</p>
</li>
</ul>
<p>If this isn't set correctly, GitHub webhooks may fail, and any links Jenkins generates will point to the wrong address (often localhost or internal IPs).</p>
<h2 id="heading-7-first-time-jenkins-setup">7. First-Time Jenkins Setup</h2>
<p>If you're running Jenkins for the first time on this server, follow this section to complete the initial setup.</p>
<p>If you already have Jenkins configured, you can skip this section — but make sure the required plugins and settings match what we use later in this guide.</p>
<ol>
<li><p>Open <code>https://jenkins.example.com</code>. Get the initial admin password:</p>
<pre><code class="language-bash">docker exec projects-jenkins-staging cat /var/jenkins_home/secrets/initialAdminPassword
</code></pre>
</li>
<li><p>Paste it, choose Install suggested plugins.</p>
</li>
<li><p>Create your admin user.</p>
</li>
<li><p>Manage Jenkins → Plugins → Available and install:</p>
<ul>
<li><p>GitHub (and GitHub Branch Source)</p>
</li>
<li><p>Pipeline: GitHub</p>
</li>
<li><p>Credentials Binding (usually preinstalled)</p>
</li>
</ul>
</li>
</ol>
<p>That's all the plugins you need for the rest of this guide.</p>
<h2 id="heading-8-add-the-github-credential">8. Add the GitHub Credential</h2>
<p>Jenkins needs permission to access your GitHub repository.</p>
<p>This is done using a GitHub Personal Access Token (PAT), which acts like a password for secure API and Git operations.</p>
<p>We’ll store this token inside Jenkins as a credential so it can pull code during pipeline execution and authenticate securely without exposing secrets in code.</p>
<p>This single credential is used both for the SCM checkout and for the deploy-time <code>git pull</code>.</p>
<ol>
<li><p>Create a Personal Access Token (classic) on GitHub with <code>repo</code> scope.</p>
</li>
<li><p>In Jenkins: Manage Jenkins → Credentials → System → Global → Add Credentials.</p>
</li>
<li><p>Fill in:</p>
<ul>
<li><p>Kind: Username with password</p>
</li>
<li><p>Username: your GitHub username</p>
</li>
<li><p>Password: the token</p>
</li>
<li><p><strong>ID:</strong> <code>github_classic_token</code> <em>(the Jenkinsfile references this exact ID)</em></p>
</li>
</ul>
</li>
</ol>
<h2 id="heading-9-create-the-pipeline-job">9. Create the Pipeline Job</h2>
<p>Now that Jenkins has access to your repository, the next step is to define how deployments should run.</p>
<p>A pipeline job tells Jenkins:</p>
<ul>
<li><p>where your code lives,</p>
</li>
<li><p>which branch to monitor,</p>
</li>
<li><p>and how to execute your deployment process.</p>
</li>
</ul>
<p>In Jenkins, create a new Pipeline job and connect it to your GitHub repository. Once this is set up, Jenkins will automatically trigger deployments whenever you push to the <code>staging</code> branch.</p>
<p>Start by creating a new job:</p>
<p>New Item → Pipeline → name it <code>projects-staging</code> → OK</p>
<p>Then configure the job:</p>
<ul>
<li><p>Under <strong>Build Triggers</strong>, enable:<br><strong>GitHub hook trigger for GITScm polling</strong></p>
</li>
<li><p>Under <strong>Pipeline</strong>:</p>
<ul>
<li><p>Definition: Pipeline script from SCM</p>
</li>
<li><p>SCM: Git</p>
</li>
<li><p>Repository URL: <code>https://github.com/&lt;org&gt;/projects-backend.git</code></p>
</li>
<li><p>Credentials: <code>github_classic_token</code></p>
</li>
<li><p>Branch: <code>*/staging</code></p>
</li>
<li><p>Script Path: <code>Jenkinsfile</code></p>
</li>
</ul>
</li>
</ul>
<p>Save the configuration.</p>
<p>At this point, Jenkins is fully connected to your repository and ready to run your deployment pipeline automatically.</p>
<h2 id="heading-10-the-jenkinsfile-deploy-only-what-changed">10. The Jenkinsfile (Deploy Only What Changed)</h2>
<p>Place this at the root of the <strong>app</strong> repo (<code>projects-backend/Jenkinsfile</code>), branch <code>staging</code>.</p>
<pre><code class="language-groovy">pipeline {
  agent any

  environment {
    PROJECT_PATH = "/projects/projects-prod-configs/projects-backend"
    COMPOSE_FILE = "docker-compose.staging.yml"
  }

  stages {

    stage('Checkout') {
      steps {
        checkout scm
        echo "Checkout completed for branch: ${env.BRANCH_NAME ?: 'staging'}"
      }
    }

    stage('Detect Changes') {
      steps {
        script {
          def changedFiles = sh(
            script: "git diff --name-only HEAD~1 HEAD",
            returnStdout: true
          ).trim()

          echo "Changed files:\n${changedFiles}"

          def services = [] as Set
          changedFiles.split('\n').each { file -&gt;
            def svc  = file =~ /^apps\/services\/([a-z0-9-]+)\//
            def gw   = file =~ /^apps\/gateways\/([a-z0-9-]+)\//
            def core = file =~ /^apps\/core\/([a-z0-9-]+)\//
            if (svc)  { services &lt;&lt; svc[0][1]  }
            if (gw)   { services &lt;&lt; gw[0][1]   }
            if (core) { services &lt;&lt; core[0][1] }
          }
          services = services.findAll { !it.endsWith('-e2e') }
          env.CHANGED_SERVICES = services.join(' ')

          echo "Services to deploy: ${env.CHANGED_SERVICES ?: '(none)'}"
        }
      }
    }

    stage('Deploy') {
      when { expression { return env.CHANGED_SERVICES?.trim() } }
      steps {
        withCredentials([usernamePassword(
          credentialsId: 'github_classic_token',
          usernameVariable: 'GIT_USER',
          passwordVariable: 'GIT_TOKEN'
        )]) {
          sh '''
            set -eu
            git config --global --add safe.directory "${PROJECT_PATH}"
            cd "${PROJECT_PATH}"
            git remote set-url origin "https://github.com/&lt;org&gt;/projects-backend.git"
            git -c credential.helper= \
                -c "credential.helper=!f() { echo username=\({GIT_USER}; echo password=\){GIT_TOKEN}; }; f" \
                pull origin staging
            docker compose -f "\({COMPOSE_FILE}" up -d --build \){CHANGED_SERVICES}
          '''
        }
        echo "Deployed: ${env.CHANGED_SERVICES}"
      }
    }

    stage('Skip Deployment') {
      when { expression { return !env.CHANGED_SERVICES?.trim() } }
      steps { echo "No service changes detected — nothing to deploy." }
    }
  }
}
</code></pre>
<p>Why each tricky line is there:</p>
<ul>
<li><p><code>git config --global --add safe.directory ...</code> — git refuses to operate on a repo whose owner UID differs from the current user's. The repo on disk is owned by <code>developer</code>, but Git inside the container runs as <code>root</code>. This whitelists the path.</p>
</li>
<li><p><code>git remote set-url origin "https://..."</code> — flips the on-disk remote to HTTPS so the <strong>token can be used</strong>. (A PAT can't authenticate <code>git@github.com:</code> URLs — those use SSH.) Idempotent — safe to re-run.</p>
</li>
<li><p><code>git -c credential.helper="!f() { echo username=...; echo password=...; }; f"</code> — feeds the username/token to git for that one command without writing the token to disk and without exposing it on the process command line.</p>
</li>
<li><p><code>${CHANGED_SERVICES}</code> is unquoted on purpose so multiple service names expand as separate args.</p>
</li>
</ul>
<h2 id="heading-11-end-to-end-test">11. End-to-End Test</h2>
<p>Before considering the setup complete, we need to verify that the entire pipeline works as expected.</p>
<p>This end-to-end test ensures that:</p>
<ul>
<li><p>GitHub webhooks are triggering Jenkins correctly,</p>
</li>
<li><p>Jenkins can detect which services changed,</p>
</li>
<li><p>and only the affected services are rebuilt and deployed.</p>
</li>
</ul>
<p>In other words, this simulates a real production deployment.</p>
<p>Start by making a small change in your repository. For example, modify a file inside:</p>
<p>apps/gateways/student-apigw/</p>
<p>Then push the change to the <code>staging</code> branch.</p>
<p>Once pushed, Jenkins should automatically trigger via the webhook. If not, you can manually click <strong>Build Now</strong>.</p>
<p>Now open the build’s <strong>Console Output</strong> and verify the flow. You should see something like:</p>
<ul>
<li><p>Checkout completed for branch: staging</p>
</li>
<li><p>Services to deploy: student-apigw</p>
</li>
<li><p>git pull origin staging (successful)</p>
</li>
<li><p>docker compose ... up -d --build student-apigw</p>
</li>
<li><p>Deployed: student-apigw</p>
</li>
</ul>
<p>If you see this sequence, your pipeline is working correctly.</p>
<p>If anything fails, don’t worry — jump to Section 12 where every common issue and its fix is documented.</p>
<h2 id="heading-12-troubleshooting-every-error-we-hit">12. Troubleshooting — Every Error We Hit</h2>
<p>This section covers real issues we faced while setting up this pipeline — and more importantly, <em>why each fix works</em>. Understanding the “why” will help you debug similar problems in your own setup.</p>
<h3 id="heading-cd-cant-cd-to-projectsprojects-prod-configsprojects-backend">cd: can't cd to /projects/projects-prod-configs/projects-backend</h3>
<p><strong>Cause:</strong><br>The Jenkinsfile runs <code>cd $PROJECT_PATH</code>, but inside the container that path doesn’t exist. This usually happens when:</p>
<ul>
<li><p>the project wasn’t cloned on the host, or</p>
</li>
<li><p>the bind mount isn’t configured correctly.</p>
</li>
</ul>
<p><strong>Fix:</strong></p>
<pre><code class="language-bash">ls /home/developer/projects/projects-prod-configs/projects-backend
# If missing: git clone -b staging &lt;url&gt; there.
</code></pre>
<p>Confirm the bind mount:</p>
<pre><code class="language-plaintext">docker inspect projects-jenkins-staging --format '{{range .Mounts}}{{.Source}} -&gt; {{.Destination}}{{println}}{{end}}'
</code></pre>
<p>If missing, recreate the container:</p>
<pre><code class="language-plaintext">docker compose -f docker-compose.staging.yml up -d --force-recreate jenkins
</code></pre>
<p><strong>Why this works:</strong></p>
<p>Jenkins runs inside a container, but your code lives on the host. The bind mount connects them. Without it, Jenkins cannot access your project directory.</p>
<h3 id="heading-fatal-detected-dubious-ownership-in-repository">fatal: detected dubious ownership in repository</h3>
<p><strong>Cause:</strong><br>Git blocks access when the repository owner differs from the current user.</p>
<ul>
<li><p>Repo owner: <code>developer</code> (host)</p>
</li>
<li><p>Git runs as: <code>root</code> (inside container)</p>
</li>
</ul>
<p><strong>Fix:</strong></p>
<pre><code class="language-plaintext">git config --global --add safe.directory "${PROJECT_PATH}"
</code></pre>
<p><strong>Why this works:</strong></p>
<p>This explicitly tells Git that the directory is trusted, bypassing ownership mismatch security restrictions.</p>
<h3 id="heading-host-key-verification-failed-could-not-read-from-remote-repository"><code>Host key verification failed</code> / <code>Could not read from remote repository</code></h3>
<h4 id="heading-cause">Cause:</h4>
<p>The repository uses SSH (<code>git@github.com:...</code>), but:</p>
<ul>
<li><p>the container has no SSH keys</p>
</li>
<li><p>no known_hosts file exists</p>
</li>
</ul>
<p>Also, GitHub tokens cannot authenticate over SSH.</p>
<p><strong>Fix (recommended):</strong></p>
<pre><code class="language-plaintext">git remote set-url origin "https://github.com/&lt;org&gt;/projects-backend.git"
</code></pre>
<p><strong>Why this works:</strong></p>
<p>HTTPS uses token-based authentication (PAT), which works inside containers without SSH configuration.</p>
<h3 id="heading-unknown-shorthand-flag-f-in-f-docker-compose"><code>unknown shorthand flag: 'f' in -f</code> ( <code>docker compose</code>)</h3>
<p><strong>Cause:</strong><br>The Docker CLI exists, but the Docker Compose plugin is missing inside the container.</p>
<p><strong>Fix:</strong></p>
<pre><code class="language-plaintext">volumes:
  - /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins:ro
</code></pre>
<p>Find your path if needed:</p>
<pre><code class="language-plaintext">find /usr -name docker-compose -type f 2&gt;/dev/null
</code></pre>
<p>Verify:</p>
<pre><code class="language-plaintext">docker exec projects-jenkins-staging docker compose version
</code></pre>
<p><strong>Why this works:</strong></p>
<p>Docker Compose v2 is a CLI plugin. Mounting this directory makes the <code>docker compose</code> command available inside the container.</p>
<h3 id="heading-wrong-timezone-in-build-timestamps-and-jenkins-ui">Wrong timezone in build timestamps and Jenkins UI</h3>
<p><strong>Fix:</strong> Set both env var and JVM flag, and bind-mount the host's clock files:</p>
<pre><code class="language-yaml">environment:
  - TZ=Asia/Dhaka
  - JAVA_OPTS=... -Duser.timezone=Asia/Dhaka
volumes:
  - /etc/localtime:/etc/localtime:ro
  - /etc/timezone:/etc/timezone:ro
</code></pre>
<p>You <strong>must</strong> recreate the container for env-var changes to take effect:</p>
<pre><code class="language-bash">docker compose -f docker-compose.staging.yml up -d --force-recreate jenkins
</code></pre>
<p><strong>Why this works:</strong><br>Jenkins runs on Java, which uses its own timezone separate from the OS.<br>By aligning OS timezone, JVM timezone, and host clock, you ensure consistent timestamps everywhere.</p>
<h3 id="heading-errsockettimeout-pnpm-install-fails">ERR_SOCKET_TIMEOUT (pnpm install fails)</h3>
<h4 id="heading-cause">Cause:</h4>
<p>If you have multiple services building in parallel and each runs pnpm install with ~1500 packages, the network gets saturated and a timeout occurs.</p>
<h4 id="heading-fixes">Fixes:</h4>
<p>a) Increase timeout + control concurrency</p>
<pre><code class="language-xml">RUN pnpm install --frozen-lockfile --ignore-scripts 
--network-timeout 600000 
--network-concurrency 8
</code></pre>
<p>Why: Gives pnpm more time and reduces network overload.</p>
<p>b) Enable pnpm cache (BuildKit)</p>
<pre><code class="language-xml">RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store 
pnpm install --frozen-lockfile --ignore-scripts
</code></pre>
<p>Why: Dependencies are cached and reused instead of downloading every time.</p>
<p>c) Avoid unnecessary rebuilds</p>
<pre><code class="language-xml">docker compose -f \(COMPOSE_FILE build \)CHANGED_SERVICES docker compose -f \(COMPOSE_FILE up -d --no-build \)CHANGED_SERVICES
</code></pre>
<p>Why: Only changed services are rebuilt → less network load → fewer failures.</p>
<h3 id="heading-container-changes-dont-apply-after-editing-docker-composeyml">Container changes don’t apply after editing docker-compose.yml</h3>
<h4 id="heading-cause">Cause:</h4>
<p>Docker compose up -d does not update running containers.</p>
<h4 id="heading-fix">Fix:</h4>
<pre><code class="language-xml">docker compose -f docker-compose.staging.yml up -d --force-recreate jenkins
</code></pre>
<p><strong>Why this works:</strong></p>
<p>This forces Docker to recreate the container with updated configuration (env, volumes, labels).</p>
<h3 id="heading-traefik-shows-default-certificate-no-https">Traefik shows default certificate (no HTTPS)</h3>
<h4 id="heading-common-causes">Common causes:</h4>
<p>DNS not pointing to server Port 80 blocked Wrong Docker network</p>
<h4 id="heading-check">Check:</h4>
<pre><code class="language-xml">dig +short jenkins.example.com docker logs projects-traefik-staging 2&gt;&amp;1 | grep -i acme
</code></pre>
<p><strong>Why this works:</strong></p>
<p>Let’s Encrypt uses HTTP-01 challenge, so it must reach your server via port 80. If DNS or networking is wrong, certificate issuance fails.</p>
<h3 id="heading-jenkins-reverse-proxy-setup-is-broken">Jenkins: "Reverse proxy setup is broken"</h3>
<h4 id="heading-fix">Fix:</h4>
<p>Set the Jenkins URL to <a href="https://jenkins.example.com/">https://jenkins.example.com/</a><br>Ensure header:</p>
<pre><code class="language-xml">X-Forwarded-Proto: https
</code></pre>
<p><strong>Why this works:</strong></p>
<p>Jenkins needs to know it's behind HTTPS. Without this, it generates incorrect URLs (http instead of https), breaking redirects and webhooks.</p>
<h2 id="heading-13-mental-model-host-vs-container">13. Mental Model: Host vs. Container</h2>
<p>Many setup mistakes come from confusing the <strong>host</strong> filesystem with the <strong>container</strong> filesystem. This table makes it explicit:</p>
<table>
<thead>
<tr>
<th>Inside the Jenkins container</th>
<th>Comes from on the host</th>
</tr>
</thead>
<tbody><tr>
<td><code>/var/jenkins_home</code></td>
<td>docker volume <code>jenkins-data</code> (Jenkins config, jobs, secrets)</td>
</tr>
<tr>
<td><code>/projects/...</code></td>
<td><code>/home/developer/projects/...</code> (your project tree)</td>
</tr>
<tr>
<td><code>/usr/bin/docker</code></td>
<td>host's <code>/usr/bin/docker</code></td>
</tr>
<tr>
<td><code>/usr/libexec/docker/cli-plugins/docker-compose</code></td>
<td>host plugin (lets <code>docker compose</code> work)</td>
</tr>
<tr>
<td><code>/var/run/docker.sock</code></td>
<td>host Docker daemon (so builds happen on the host's engine)</td>
</tr>
<tr>
<td><code>/etc/localtime</code>, <code>/etc/timezone</code></td>
<td>host clock</td>
</tr>
<tr>
<td><code>~/.ssh</code></td>
<td><strong>nothing</strong> — that's why SSH-to-GitHub doesn't work without extra setup</td>
</tr>
</tbody></table>
<p>When debugging, always ask: <em>"Inside which filesystem is this command running, and does the file/folder it's looking for exist there?"</em></p>
<h2 id="heading-14-daily-operations-cheat-sheet">14. Daily Operations Cheat Sheet</h2>
<pre><code class="language-bash"># Recreate Jenkins after changing compose
cd /home/developer/Projects/projects-prod-configs
docker compose -f docker-compose.staging.yml up -d --force-recreate jenkins

# Tail Jenkins logs
docker logs -f projects-jenkins-staging

# Open a shell inside the Jenkins container
docker exec -it projects-jenkins-staging bash

# From inside the container — sanity checks
docker compose version
ls /projects/projects-prod-configs/projects-backend
git -C /projects/projects-prod-configs/projects-backend remote -v

# Manually trigger the same deploy the pipeline does
cd /projects/projects-configs/projects-backend
git pull origin staging
docker compose -f docker-compose.staging.yml up -d --build student-apigw

# Inspect Traefik routing decisions
docker logs projects-traefik-staging 2&gt;&amp;1 | grep -i jenkins

# Check renewed certs
docker exec projects-traefik-staging cat /etc/traefik/acme.json | head -50
</code></pre>
<h2 id="heading-15-what-id-do-differently-next-time">15. What I'd Do Differently Next Time</h2>
<ul>
<li><p><strong>Pre-build a base image</strong> with all node_modules baked in. With ~1500 packages × 15 services, every clean build re-downloads ~22k tarballs. A shared base cuts that 90%.</p>
</li>
<li><p><strong>Run a private npm proxy</strong> (Verdaccio / Nexus / GitHub Packages) on the same Docker network — eliminates flaky <code>npmjs.org</code> timeouts entirely.</p>
</li>
<li><p><strong>Per-service Jenkinsfile</strong> if your services drift apart in tooling. With one Jenkinsfile, every team contends for the same pipeline definition.</p>
</li>
<li><p><strong>Replace</strong> <code>git diff HEAD~1 HEAD</code> with <code>git diff $(git merge-base HEAD origin/staging~1) HEAD</code> so squash-merges and force-pushes don't accidentally skip services.</p>
</li>
<li><p><strong>Move secrets to a vault</strong> (HashiCorp Vault / AWS Secrets Manager / Doppler). PATs in Jenkins work, but rotation across many jobs is painful.</p>
</li>
<li><p><strong>Use Jenkins' Configuration-as-Code (JCasC)</strong> so the entire Jenkins setup (jobs, credentials definitions, plugins) is in git. Then a server rebuild is a one-command operation.</p>
</li>
</ul>
<h2 id="heading-closing-thoughts">Closing Thoughts</h2>
<p>The pipeline itself is just three stages: <strong>Checkout → Detect Changes → Deploy</strong> — but a real production setup is mostly about <strong>plumbing</strong>: reverse proxy, certificates, bind-mounts, credentials, timezones, build caches. None of these are exotic. Together they decide whether your Friday-afternoon deploy goes silently green or eats your weekend.</p>
<p>Follow sections 1–11 to get a working pipeline. Bookmark section 12 to keep it working.</p>
<p>Happy shipping.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Fix a Failing GitHub PR: Debugging CI, Lint Errors, and Build Errors Step by Step ]]>
                </title>
                <description>
                    <![CDATA[ While many guides explain how to set up Continuous Integration pipelines, not very many show you how to debug them when things go wrong across multiple layers. This is a common experience when contrib ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-fix-failing-github-pr-ci-lint-build-errors/</link>
                <guid isPermaLink="false">69e9033dbca83cce6c5f0209</guid>
                
                    <category>
                        <![CDATA[ GitHub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ debugging ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ markdown ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ qacheampong ]]>
                </dc:creator>
                <pubDate>Wed, 22 Apr 2026 17:19:57 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/29733bad-98af-4d6e-9fb6-93d55e8f87fd.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>While many guides explain how to set up Continuous Integration pipelines, not very many show you how to debug them when things go wrong across multiple layers.</p>
<p>This is a common experience when contributing to open source: you make a small change, open a pull request, and suddenly everything fails.</p>
<p>Not just one check, but multiple:</p>
<ul>
<li><p>Lint errors</p>
</li>
<li><p>YAML validation issues</p>
</li>
<li><p>Build failures</p>
</li>
<li><p>Deployment failures</p>
</li>
</ul>
<p>Even more confusing, you may see errors in parts of the codebase you didn’t modify.</p>
<p>In this article, you'll learn how to debug these issues step by step. The goal is not just to fix one pull request, but to understand how CI systems validate your changes.</p>
<p>This guide is based on a real debugging experience from contributing to an open source documentation project.</p>
<p>While this example comes from a documentation project, the debugging workflow applies to many repositories that use CI pipelines, linting tools, and automated builds.</p>
<h3 id="heading-table-of-contents">Table of Contents:</h3>
<ul>
<li><p><a href="#heading-understanding-the-ci-pipeline-whats-actually-happening">Understanding the CI Pipeline (What’s Actually Happening)</a></p>
</li>
<li><p><a href="#heading-how-a-ci-pipeline-processes-your-pull-request">How a CI Pipeline Processes Your Pull Request</a></p>
</li>
<li><p><a href="#heading-a-practical-debugging-workflow">A Practical Debugging Workflow</a></p>
<ul>
<li><p><a href="#heading-step-1-fix-authentication-and-permission-issues">Step 1: Fix Authentication and Permission Issues</a></p>
</li>
<li><p><a href="#heading-step-2-run-lint-checks-locally">Step 2: Run Lint Checks Locally</a></p>
</li>
<li><p><a href="#heading-step-3-fix-common-markdown-lint-errors">Step 3: Fix Common Markdown Lint Errors</a></p>
</li>
<li><p><a href="#heading-step-4-fix-yaml-inside-markdown-code-blocks">Step 4: Fix YAML Inside Markdown Code Blocks</a></p>
</li>
<li><p><a href="#heading-step-5-fix-build-errors-after-lint-passes">Step 5: Fix Build Errors After Lint Passes</a></p>
</li>
<li><p><a href="#heading-step-6-debug-cascading-ci-failures">Step 6: Debug Cascading CI Failures</a></p>
</li>
<li><p><a href="#heading-step-7-handle-git-issues-during-ci-debugging">Step 7: Handle Git Issues During CI Debugging</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-key-takeaways">Key Takeaways</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h3 id="heading-prerequisites"><strong>Prerequisites</strong></h3>
<p>To follow this guide, you should have:</p>
<ul>
<li><p>Basic familiarity with Git and pull requests</p>
</li>
<li><p>A GitHub account</p>
</li>
<li><p>Some exposure to CI/CD concepts (helpful but not required)</p>
</li>
</ul>
<h2 id="heading-understanding-the-ci-pipeline-whats-actually-happening"><strong>Understanding the CI Pipeline (What’s Actually Happening)</strong></h2>
<p>In many projects, you will see the term CI/CD, which stands for Continuous Integration and Continuous Deployment (or Delivery).</p>
<p>In this guide, we'll focus specifically on the CI part – that is, Continuous Integration. This refers to the automated checks that run when you push code or open a pull request. These checks validate your changes before they're merged into the main codebase.</p>
<p>CD (Continuous Deployment/Delivery), on the other hand, typically handles what happens after those checks pass, such as deploying the application.</p>
<p>Understanding this distinction is important because most of the issues we debug in this guide happen during the CI stage.</p>
<p>Most repositories run multiple automated checks when you open a pull request:</p>
<ul>
<li><p><strong>Linting tools</strong> (for example, markdownlint, yamllint) enforce formatting rules</p>
</li>
<li><p><strong>Build systems</strong> (for example, mdBook) validate structure and generate output</p>
</li>
<li><p><strong>Deployment checks</strong> (for example, Netlify) ensure that the site can be built and served</p>
</li>
<li><p><strong>Merge controllers</strong> (for example, Tide) enforce approval policies</p>
</li>
</ul>
<p>A key point to remember: CI systems validate the <strong>entire set of files in your commit,</strong> not just the lines you changed.</p>
<h2 id="heading-how-a-ci-pipeline-processes-your-pull-request"><strong>How a CI Pipeline Processes Your Pull Request</strong></h2>
<p>When you push code or open a pull request, the CI pipeline runs several checks in sequence.</p>
<p>Let’s visualize how these checks are connected in a typical CI pipeline.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69d09527e466e2b762fdff59/9cecca6e-e000-46e3-a40e-cb353fc89ff8.png" alt="A CI pipeline diagram showing lint, build, and deployment steps with failure loops returning to code fixes." style="display: block;" width="2348" height="1516" loading="lazy">

<p>Figure: A simplified CI pipeline showing how linting, build, and deployment checks are executed sequentially.</p>
<p>The above diagram shows a sequential CI pipeline with feedback loops, where failures at any stage return you to fix the issue before continuing.</p>
<p>Let’s break down what this diagram shows:</p>
<ol>
<li><p>You start by pushing code or opening a pull request.</p>
</li>
<li><p>The CI pipeline begins running automated checks.</p>
</li>
<li><p>The first set of checks typically includes linting tools like markdownlint or yamllint.</p>
<ul>
<li><p>If linting fails, the pipeline stops, and you must fix formatting issues before continuing.</p>
</li>
<li><p>If linting passes, the pipeline moves to the build step (for example, mdBook in documentation projects).</p>
</li>
<li><p>If the build fails, it usually means there is a structural issue, such as duplicate entries or invalid references.</p>
</li>
</ul>
</li>
<li><p>After a successful build, deployment checks (such as Netlify previews) run.</p>
<ul>
<li>If deployment fails, the issue is often related to configuration or build output.</li>
</ul>
</li>
<li><p>If all steps pass, the pull request becomes ready for review.</p>
</li>
</ol>
<h2 id="heading-a-practical-debugging-workflow"><strong>A Practical Debugging Workflow</strong></h2>
<h3 id="heading-step-1-fix-authentication-and-permission-issues">Step 1: Fix Authentication and Permission Issues</h3>
<p>Before CI runs, your push can fail due to authentication errors.</p>
<p>Example error:</p>
<pre><code class="language-shell">refusing to allow a Personal Access Token to create or update workflow
</code></pre>
<p>This happens because GitHub requires special permissions when your commit includes files under:</p>
<pre><code class="language-shell">.github/workflows/
</code></pre>
<p>The solution is to regenerate your Personal Access Token (PAT) with:</p>
<ul>
<li><p><code>repo</code> access</p>
</li>
<li><p><code>workflow</code> permission</p>
</li>
</ul>
<h3 id="heading-step-2-run-lint-checks-locally">Step 2: Run Lint Checks Locally</h3>
<p>Relying only on CI feedback slows you down because you have to push changes and wait for the pipeline to run before seeing errors.</p>
<p>Running checks locally allows you to catch issues immediately before pushing your code.</p>
<p>In practice, you should do both:</p>
<ul>
<li><p>Run checks locally to catch errors early and reduce iteration time</p>
</li>
<li><p>Use CI as the final validation to ensure your changes meet the repository’s standards</p>
</li>
</ul>
<p>Think of local checks as your first line of defense, and CI as the final gate before your code is accepted.</p>
<p>Here's an example (Markdown linting):</p>
<pre><code class="language-shell">npm install -g markdownlint-cli2
markdownlint-cli2 docs/**/*.md
</code></pre>
<h3 id="heading-step-3-fix-common-markdown-lint-errors">Step 3: Fix Common Markdown Lint Errors</h3>
<p>Here are some common issues you may encounter:</p>
<h4 id="heading-1-non-descriptive-links">1. Non-descriptive links</h4>
<p>Non-descriptive links like "here" don't give readers any context about where the link leads. This makes documentation harder to understand and less accessible, especially for users relying on screen readers.</p>
<p>Instead of writing:</p>
<pre><code class="language-shell">[here](https://example.com)
</code></pre>
<p>Use descriptive text like:</p>
<pre><code class="language-shell">[command help documentation](https://example.com)
</code></pre>
<h4 id="heading-2-line-length-violations">2. Line length violations</h4>
<p>Many projects enforce a maximum line length (often around 80 characters) to improve readability across different devices and editors.</p>
<p>If a line is too long, you can split it into multiple lines without changing the meaning.</p>
<p>To do this, break the line at natural points such as spaces between words or after punctuation. Avoid breaking words or disrupting the sentence structure.<br>For example:</p>
<pre><code class="language-shell">This is a long sentence that should be split across multiple
lines to satisfy lint rules.
</code></pre>
<h4 id="heading-3-list-indentation-issues">3. List indentation issues</h4>
<p>List indentation errors occur when nested list items aren't aligned consistently. This can break formatting and cause linting errors.</p>
<p>To avoid this, just make sure you use consistent spacing (usually 2 spaces per level).</p>
<p>Example (incorrect):</p>
<pre><code class="language-shell">- Item 1
 - Subitem
</code></pre>
<p>Correct version:</p>
<pre><code class="language-shell">- Item 1
  - Subitem
</code></pre>
<h3 id="heading-step-4-fix-yaml-inside-markdown-code-blocks">Step 4: Fix YAML Inside Markdown Code Blocks</h3>
<p>YAML has strict formatting rules, including proper indentation, key-value structure, and consistent spacing.</p>
<p>Even when YAML appears inside a markdown code block, tools like yamllint still validate its structure.</p>
<p>Example (incorrect):</p>
<pre><code class="language-yaml">metadata:
annotations:
</code></pre>
<p>Correct version:</p>
<pre><code class="language-yaml">metadata:
  annotations:
    capi.metal3.io/unhealthy: "true"
</code></pre>
<p>In the incorrect example, <code>annotations</code> is not properly nested under <code>metadata</code>, and no key-value pair is defined.</p>
<p>In the corrected version:</p>
<ul>
<li><p><code>annotations</code> is properly indented under <code>metadata</code></p>
</li>
<li><p>a valid key-value pair is added (<code>capi.metal3.io/unhealthy: "true"</code>)</p>
</li>
</ul>
<p>This structure satisfies YAML’s requirement for proper hierarchy and formatting.</p>
<h3 id="heading-step-5-fix-build-errors-after-lint-passes">Step 5: Fix Build Errors After Lint Passes</h3>
<p>Passing lint checks doesn't guarantee that your build will succeed.</p>
<p>This is because linting focuses on syntax and formatting, while the build process validates the structure and integrity of the entire project.</p>
<p>Build failures often occur due to issues such as:</p>
<ul>
<li><p>Duplicate entries in navigation files</p>
</li>
<li><p>Missing or incorrectly referenced files</p>
</li>
<li><p>Invalid configuration settings</p>
</li>
</ul>
<p>Even if your syntax is correct, the build system ensures everything connects properly.</p>
<p>For example, in documentation projects using tools like mdBook, a duplicate entry in <code>SUMMARY.md</code> can cause the build to fail even when all files pass lint checks.</p>
<h3 id="heading-step-6-debug-cascading-ci-failures">Step 6: Debug Cascading CI Failures</h3>
<p>CI pipelines are layered. One failure can trigger multiple downstream failures.</p>
<p>For example, imagine a YAML indentation error:</p>
<pre><code class="language-shell">YAML error → build fails → deploy fails → multiple checks fail
</code></pre>
<p>To fix this:</p>
<ol>
<li><p>Identify the first failing step in the CI logs</p>
</li>
<li><p>Fix that issue</p>
</li>
<li><p>Re-run the pipeline</p>
</li>
</ol>
<p>In this example, the YAML indentation error is the root cause. Once you fix the YAML formatting, the lint check passes, which allows the build to proceed and the deployment step to succeed.</p>
<p>This is why it is important to always fix the first failure in the pipeline rather than trying to address all errors at once.</p>
<h3 id="heading-step-7-handle-git-issues-during-ci-debugging">Step 7: Handle Git Issues During CI Debugging</h3>
<p>When working with updated branches, you may encounter:</p>
<ul>
<li><p>Diverged branches</p>
</li>
<li><p>Rebase conflicts</p>
</li>
<li><p>Push rejections</p>
</li>
</ul>
<p>To resolve these issues, you typically need to update your branch using one of two approaches:</p>
<h4 id="heading-option-1-rebase-clean-history">Option 1: Rebase (clean history)</h4>
<pre><code class="language-shell">git pull --rebase
</code></pre>
<p>Rebasing rewrites your commit history so your changes appear on top of the latest version of the branch.</p>
<p>Use carefully:</p>
<ul>
<li><p>Only rebase your own branches</p>
</li>
<li><p>Avoid rebasing shared branches</p>
</li>
</ul>
<h4 id="heading-option-2-merge-safer">Option 2: Merge (safer)</h4>
<pre><code class="language-shell">git pull --no-rebase
</code></pre>
<p>Merging preserves the full commit history and is safer when working with others, but it may introduce additional merge commits.</p>
<h4 id="heading-pushing-your-changes-safely">Pushing your changes safely</h4>
<p>After updating your branch, you may need to push changes:</p>
<pre><code class="language-shell">git push --force-with-lease
</code></pre>
<p>Avoid using:</p>
<pre><code class="language-shell">git push --force
</code></pre>
<p>The <code>--force</code> option can overwrite the other contributors’ work. The <code>--force-with-lease</code> option is safer because it only pushes if the remote branch has not changed unexpectedly.</p>
<h2 id="heading-key-takeaways"><strong>Key Takeaways</strong></h2>
<ul>
<li><p>CI validates your entire commit, not just the specific lines you changed</p>
</li>
<li><p>Linting and build systems enforce different rules</p>
</li>
<li><p>YAML inside markdown must be structurally correct</p>
</li>
<li><p>Documentation builds can fail due to structural issues</p>
</li>
<li><p>Running checks locally significantly reduces debugging time</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Debugging a failing pull request isn't just about fixing syntax errors.</p>
<p>You also need to understand how different systems interact:</p>
<ul>
<li><p>Version control</p>
</li>
<li><p>CI pipelines</p>
</li>
<li><p>Linting tools</p>
</li>
<li><p>Build processes</p>
</li>
</ul>
<p>Once you understand how these systems work together, you can debug issues systematically instead of guessing.</p>
<p>The next time your pull request fails, you will know exactly where to start and how to fix it.</p>
<p>Debugging CI issues may feel overwhelming at first, but with a structured approach, you can turn failures into a clear path for improvement.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Complete Flutter CI/CD Pipeline with Codemagic: From PR Quality Gates to Automated Store Releases ]]>
                </title>
                <description>
                    <![CDATA[ If you've spent any time shipping Flutter apps manually, you already know the drill. Someone on the team finishes a feature, builds the APK locally, signs it (hopefully with the right keystore), uploa ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-a-complete-flutter-ci-cd-pipeline-with-codemagic/</link>
                <guid isPermaLink="false">69c1dcba30a9b81e3ac436d8</guid>
                
                    <category>
                        <![CDATA[ code magic ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mobile app ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ code ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Tue, 24 Mar 2026 00:37:14 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/914de6f3-5b7f-48ff-a092-1f8d095202e5.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've spent any time shipping Flutter apps manually, you already know the drill. Someone on the team finishes a feature, builds the APK locally, signs it (hopefully with the right keystore), uploads it somewhere, and notifies the QA team. Repeat for iOS. Repeat for staging. Repeat for production.</p>
<p>And somewhere in that chain, something often goes wrong: an incorrect API key, a missed signing step, a build that worked on one machine and failed on another.</p>
<p>The solution is a properly configured CI/CD pipeline that takes that entire chain out of human hands. And in this article, we're building exactly that using Codemagic.</p>
<h2 id="heading-what-is-codemagic">What is Codemagic?</h2>
<p>Codemagic is a dedicated CI/CD platform built from the ground up specifically for mobile applications.</p>
<p>Unlike general-purpose CI platforms, Codemagic understands Flutter natively. It ships with Flutter pre-installed on its build machines, has dedicated support for Apple code signing, and integrates directly with both the Google Play Store and App Store Connect. This means less configuration noise and more focus on what actually matters , which is your deployment logic.</p>
<p>The pipeline we'll be building covers three distinct stages across both Android and iOS:</p>
<ul>
<li><p>A pull request gate that blocks unverified code from reaching your base branch</p>
</li>
<li><p>A staging pipeline that injects real environment config, builds signed artifacts, and ships them to testers via Firebase App Distribution and TestFlight</p>
</li>
<li><p>A production pipeline that obfuscates builds, uploads crash symbols to Sentry, and submits directly to the Play Store and App Store Connect</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-understanding-codemagics-yaml-approach">Understanding Codemagic's YAML Approach</a></p>
</li>
<li><p><a href="#heading-pipeline-architecture">Pipeline Architecture</a></p>
</li>
<li><p><a href="#heading-the-helper-scripts">The Helper Scripts</a></p>
</li>
<li><p><a href="#heading-pr-quality-gate">PR Quality Gate</a></p>
</li>
<li><p><a href="#heading-android-pipeline">Android Pipeline</a></p>
</li>
<li><p><a href="#heading-ios-pipeline">iOS Pipeline</a></p>
</li>
<li><p><a href="#heading-environment-variables-and-secrets-reference">Environment Variables and Secrets Reference</a></p>
</li>
<li><p><a href="#heading-end-to-end-flow">End-to-End Flow</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You'll need the following before starting:</p>
<ul>
<li><p>A Flutter app with functional Android and iOS builds</p>
</li>
<li><p>A Codemagic account with your repository connected</p>
</li>
<li><p>A Firebase project with App Distribution set up</p>
</li>
<li><p>A Sentry project configured for your app</p>
</li>
<li><p>A Google Play Console app with at least an internal track ready</p>
</li>
<li><p>An Apple Developer account with App Store Connect access</p>
</li>
<li><p>A Google Play service account with the necessary API permissions</p>
</li>
<li><p>Familiarity with writing Bash scripts</p>
</li>
</ul>
<h2 id="heading-understanding-codemagics-yaml-approach">Understanding Codemagic's YAML Approach</h2>
<p>Codemagic offers a visual workflow editor for teams that prefer a GUI – but we're not using that here. The <code>codemagic.yaml</code> approach gives you version-controlled, reviewable, fully reproducible pipeline definitions that live right alongside your application code. Any change to the pipeline goes through the same PR process as any other change. That matters in a team environment.</p>
<p>The file lives at the root of your project:</p>
<pre><code class="language-plaintext">your-flutter-app/
  codemagic.yaml     
  lib/
  android/
  ios/
  scripts/
</code></pre>
<p>Codemagic detects this file when a build is triggered and executes the appropriate workflow based on the rules you define. One file, multiple workflows, all environments – no duplication.</p>
<h2 id="heading-pipeline-architecture">Pipeline Architecture</h2>
<p>Before writing any YAML, it helps to define exactly what the pipeline needs to do. The use case here is a team with three protected branches: <code>develop</code>, <code>staging</code>, and <code>production</code>. Each branch represents a distinct stage in the release lifecycle, and the pipeline behaves differently depending on which branch triggered it.</p>
<p>Here is how the three environments map to pipeline behaviour:</p>
<p><strong>PR into develop</strong>: When a developer raises a pull request targeting the <code>develop</code> branch, a quality gate workflow fires. It runs code formatting checks, static analysis, the full test suite, and enforces a minimum coverage threshold. The PR cannot be considered clean until all of these pass.</p>
<p><strong>Push to develop or staging</strong>: When code lands on either of these branches, the platform-specific build pipelines trigger. They detect the target branch, inject the correct environment configuration (dev or staging API keys), build signed artifacts, and distribute them to the appropriate testing channels: Firebase App Distribution for Android, TestFlight for iOS.</p>
<p><strong>Push to production</strong>: When code reaches the production branch, the pipelines switch into release mode. Builds are obfuscated, debug symbols are uploaded to Sentry for crash observability, and the final artifacts are submitted directly to the Play Store and App Store Connect.</p>
<p>Your project structure will look like this:</p>
<pre><code class="language-plaintext">codemagic.yaml

scripts/
  generate_config.sh
  quality_checks.sh
  upload_symbols.sh

lib/
  core/
    env/
      env_ci.dart       
      env_ci.g.dart     
</code></pre>
<h2 id="heading-the-helper-scripts">The Helper Scripts</h2>
<p>Rather than cramming logic directly into YAML, this pipeline delegates its core operations to three Bash scripts that live in a <code>scripts/</code> folder at the project root. This keeps the YAML readable and, crucially, means you can run the exact same logic on your local machine that CI runs – eliminating an entire class of "works on my machine" issues.</p>
<p>Make all three scripts executable before committing them:</p>
<pre><code class="language-bash">chmod +x scripts/generate_config.sh
chmod +x scripts/quality_checks.sh
chmod +x scripts/upload_symbols.sh
</code></pre>
<h3 id="heading-generateconfigsh">generate_config.sh</h3>
<p>Injecting secrets safely is one of the hardest CI/CD problems in mobile development. The strategy here avoids committing credentials entirely: a Dart file with placeholder values is committed to source control, and at build time the script replaces those placeholders with real values sourced from Codemagic's encrypted secret storage.</p>
<pre><code class="language-bash">#!/usr/bin/env bash
set -euo pipefail

# Usage: ./scripts/generate_config.sh ENV_NAME BASE_URL ENCRYPTION_KEY
ENV_NAME=${1:-}
BASE_URL=${2:-}
ENCRYPTION_KEY=${3:-}

TEMPLATE="lib/core/env/env_ci.dart"
OUT="lib/core/env/env_ci.g.dart"

if [ -z "\(ENV_NAME" ] || [ -z "\)BASE_URL" ] || [ -z "$ENCRYPTION_KEY" ]; then
  echo "Usage: $0 &lt;env-name&gt; &lt;base-url&gt; &lt;encryption-key&gt;"
  exit 2
fi

sed -e "s|&lt;&lt;BASE_URL&gt;&gt;|$BASE_URL|g" \
    -e "s|&lt;&lt;ENCRYPTION_KEY&gt;&gt;|$ENCRYPTION_KEY|g" \
    -e "s|&lt;&lt;ENV_NAME&gt;&gt;|$ENV_NAME|g" \
    "\(TEMPLATE" &gt; "\)OUT"

echo "✅ Generated config for $ENV_NAME"
</code></pre>
<p><strong>How it works:</strong></p>
<p><code>set -euo pipefail</code> enforces strict failure behaviour. <code>-e</code> exits immediately on any failed command, <code>-u</code> exits on undefined variables, and <code>-o pipefail</code> catches failures anywhere in a pipeline – not just the last command. In CI, silent failures can produce broken builds that look like they succeeded. This line prevents that.</p>
<p>The script takes three positional arguments: the environment name (<code>dev</code>, <code>staging</code>, or <code>production</code>), the API base URL, and an encryption or API key. The <code>${1:-}</code> syntax defaults to an empty string if an argument is missing, which the validation block then catches explicitly with a clear usage message and an exit code of <code>2</code> (the conventional code for incorrect usage).</p>
<p>At the heart of the script, <code>sed</code> performs three placeholder replacements in a single pass over the template file, writing the result to <code>env_ci.g.dart</code>. That generated file must be added to <code>.gitignore</code>. It only ever exists inside a running build or on a developer's local machine after they run the script manually.</p>
<p>The two Dart files involved have completely different roles:</p>
<p><code>env_ci.dart</code> – committed to source control, contains only placeholders:</p>
<pre><code class="language-dart">// lib/core/env/env_ci.dart
class EnvConfig {
  static const String baseUrl = '&lt;&lt;BASE_URL&gt;&gt;';
  static const String encryptionKey = '&lt;&lt;ENCRYPTION_KEY&gt;&gt;';
  static const String environment = '&lt;&lt;ENV_NAME&gt;&gt;';
}
</code></pre>
<p><code>env_ci.g.dart</code> – generated at build time, contains real values, never committed:</p>
<pre><code class="language-dart">// lib/core/env/env_ci.g.dart
// GENERATED FILE — DO NOT COMMIT
class EnvConfig {
  static const String baseUrl = 'https://staging.api.example.com';
  static const String encryptionKey = 'sk_live_xxxxx';
  static const String environment = 'staging';
}
</code></pre>
<p>Add the generated file to <code>.gitignore</code>:</p>
<pre><code class="language-plaintext"># Generated environment config
lib/core/env/env_ci.g.dart
</code></pre>
<h3 id="heading-qualitycheckssh">quality_checks.sh</h3>
<p>This script defines what passing quality means for your codebase. Every check it runs is a gate: if any step fails, the script stops immediately and the build fails.</p>
<pre><code class="language-bash">#!/usr/bin/env bash
set -euo pipefail

echo "🚀 Running quality checks"

dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test --no-pub --coverage

if command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1; then
  dart_code_metrics analyze lib --reporter=console || true
fi

echo "✅ Quality checks passed"
</code></pre>
<p><strong>What each step does:</strong></p>
<p><code>dart format --output=none --set-exit-if-changed .</code>: checks that all Dart files are formatted correctly without modifying them. If any file doesn't match the formatter's output, the command exits with a non-zero code, failing the build. Formatting is non-negotiable here.</p>
<p><code>flutter analyze</code>: runs Dart's static analyser across the entire project. It catches null safety violations, unused imports, missing awaits, dead code, and a wide range of structural issues before they reach a reviewer's eyes.</p>
<p><code>flutter test --no-pub --coverage</code>: runs the full test suite and generates a coverage report at <code>coverage/lcov.info</code>. The <code>--no-pub</code> flag skips <code>pub get</code> since dependencies are already installed. The coverage file is used downstream to enforce a minimum threshold.</p>
<p>The <code>dart_code_metrics</code> block is deliberately optional and non-blocking (<code>|| true</code>). The tool may not be installed in every environment, and its findings are advisory rather than hard failures. You can remove the <code>|| true</code> later to make it mandatory once your team has adopted the tool.</p>
<p>The final <code>echo</code> line only executes if every step above it passed , because <code>set -e</code> would have exited the script on any earlier failure. If you see it in the logs, the branch is clean.</p>
<h3 id="heading-uploadsymbolssh">upload_symbols.sh</h3>
<p>When Flutter production builds are compiled with <code>--obfuscate</code>, stack traces in crash reports become unreadable. This script uploads the debug symbol files that Sentry needs to reverse that obfuscation and show readable crash reports.</p>
<pre><code class="language-bash">#!/usr/bin/env bash
set -euo pipefail

RELEASE=${1:-}

[ -z "$RELEASE" ] &amp;&amp; exit 2

if ! command -v sentry-cli &gt;/dev/null 2&gt;&amp;1; then
  exit 0
fi

sentry-cli releases new "$RELEASE" || true
sentry-cli upload-dif build/symbols || true
sentry-cli releases finalize "$RELEASE" || true

echo "✅ Symbols uploaded for release $RELEASE"
</code></pre>
<p><strong>How it works:</strong></p>
<p>The script takes a single argument: a release identifier. In practice, this is always the short Git commit SHA, passed from the workflow as <code>$(git rev-parse --short HEAD)</code>. This ties the uploaded symbols, the deployed build, and the crash reports in Sentry to the exact same commit , which is essential for production debugging.</p>
<p>If <code>sentry-cli</code> is not installed in the environment, the script exits with <code>0</code> rather than failing. This makes symbol uploads environment-aware: production machines install the CLI, development environments skip the step cleanly without breaking the build.</p>
<p>Each <code>sentry-cli</code> command uses <code>|| true</code> for resilience. Symbol uploads should never block a deployment , if the upload encounters a transient issue, the build should still succeed and the symbols can be re-uploaded manually from the stored artifacts.</p>
<p>The three commands do the following in sequence: <code>releases new</code> registers the release version in Sentry, <code>upload-dif</code> sends the debug information files from <code>build/symbols</code> (generated by <code>--split-debug-info</code>), and <code>releases finalize</code> marks the release as deployed and ready to aggregate crash reports.</p>
<h2 id="heading-the-codemagicyaml-structure">The codemagic.yaml Structure</h2>
<p>A <code>codemagic.yaml</code> file is organized around workflows. Each workflow is an independent pipeline definition with its own trigger rules, environment configuration, build scripts, and publishing targets. Multiple workflows live inside the same file under a top-level <code>workflows</code> key.</p>
<p>The skeleton looks like this:</p>
<pre><code class="language-yaml">workflows:
  pr-quality-gate:
    # triggers on pull requests
    # runs quality checks only

  android-pipeline:
    # triggers on push to develop, staging, production
    # handles Android builds and distribution

  ios-pipeline:
    # triggers on push to develop, staging, production
    # handles iOS builds and distribution
</code></pre>
<p>Each workflow can define its own machine type, environment variables, triggering conditions, and step scripts. This is what makes a single <code>codemagic.yaml</code> powerful: you're not managing three separate files, but you still get complete isolation between pipeline stages.</p>
<h2 id="heading-pr-quality-gate">PR Quality Gate</h2>
<p>Every PR raised against <code>develop</code> must pass a quality gate before any merge is allowed. This workflow runs on Codemagic's Linux machines since it doesn't need to produce a signed artifact for any platform – it only needs to verify the code.</p>
<pre><code class="language-yaml">workflows:
  pr-quality-gate:
    name: PR Quality Gate
    max_build_duration: 30
    instance_type: linux_x2

    triggering:
      events:
        - pull_request
      branch_patterns:
        - pattern: develop
          include: true
          source: true

    environment:
      flutter: stable

    scripts:
      - name: Install dependencies
        script: flutter pub get

      - name: Run quality checks
        script: ./scripts/quality_checks.sh

      - name: Enforce coverage threshold
        script: |
          COVERAGE=\((lcov --summary coverage/lcov.info | grep lines | awk '{print \)2}' | sed 's/%//')
          if [ \((echo "\)COVERAGE &lt; 70" | bc) -eq 1 ]; then
            echo "Test coverage is at ${COVERAGE}% — minimum required is 70%"
            exit 1
          fi
          echo "Coverage at ${COVERAGE}% — threshold met"

    publishing:
      email:
        recipients:
          - your-team@example.com
        notify:
          success: true
          failure: true
</code></pre>
<p>Let's walk through what each section is doing.</p>
<p><code>instance_type: linux_x2</code></p>
<p>Codemagic offers different machine types for different workloads. For a quality gate that only needs to run Dart tooling, a Linux machine is perfectly sufficient and significantly cheaper than a macOS instance. You reserve the macOS machines for builds that actually need Xcode.</p>
<p><code>triggering</code></p>
<p>This is how Codemagic decides when to run a workflow. The <code>pull_request</code> event fires whenever a PR is opened or updated. The <code>branch_patterns</code> block tells Codemagic to watch for PRs targeting <code>develop</code> specifically. The <code>source: true</code> flag means this pattern applies to the target branch of the PR, not the source branch – so any branch raising a PR into <code>develop</code> will trigger this workflow.</p>
<p><code>environment</code></p>
<p>Codemagic's Flutter-aware machines come with multiple Flutter versions available. Setting <code>flutter: stable</code> pins the workflow to the current stable channel without requiring any manual SDK installation step. This is one of the areas where Codemagic saves setup time compared to a general-purpose runner.</p>
<p><strong>Quality checks script</strong></p>
<p>The workflow delegates to <code>quality_checks.sh</code> rather than inlining commands. This keeps the YAML readable and ensures the exact same logic runs when a developer calls the script locally. The script handles formatting, analysis, and test execution internally.</p>
<p><strong>Coverage enforcement</strong></p>
<p>After the tests run, <code>lcov</code> parses the coverage report generated by <code>flutter test --coverage</code> and extracts the line coverage percentage. If it falls below 70%, the build fails with a clear message. This threshold is something your team should agree on , 70% is a reasonable starting point for most projects.</p>
<p><code>publishing</code></p>
<p>Codemagic has native email notification support built in. Rather than scripting <code>echo</code> statements into CI logs, you declare recipients directly in the workflow and Codemagic handles delivery. Both success and failure states are covered.</p>
<h2 id="heading-android-pipeline">Android Pipeline</h2>
<p>The Android workflow handles all three environments in a single workflow definition, using Codemagic's environment variable groups and conditional scripting to behave differently depending on which branch triggered the build.</p>
<pre><code class="language-yaml">  android-pipeline:
    name: Android Build &amp; Release
    max_build_duration: 60
    instance_type: linux_x2

    triggering:
      events:
        - push
      branch_patterns:
        - pattern: develop
          include: true
        - pattern: staging
          include: true
        - pattern: production
          include: true

    environment:
      flutter: stable
      android_signing:
        - android_keystore
      groups:
        - staging_secrets
        - production_secrets
        - firebase_credentials
        - sentry_credentials

    scripts:
      - name: Install dependencies
        script: flutter pub get

      - name: Detect environment
        script: |
          BRANCH=$(git rev-parse --abbrev-ref HEAD)
          if [ "$BRANCH" = "develop" ]; then
            echo "ENV=dev" &gt;&gt; $CM_ENV
          elif [ "$BRANCH" = "staging" ]; then
            echo "ENV=staging" &gt;&gt; $CM_ENV
          else
            echo "ENV=production" &gt;&gt; $CM_ENV
          fi

      - name: Generate environment config
        script: |
          if [ "$ENV" = "dev" ]; then
            ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"
          elif [ "$ENV" = "staging" ]; then
            ./scripts/generate_config.sh staging "\(STAGING_BASE_URL" "\)STAGING_API_KEY"
          else
            ./scripts/generate_config.sh production "\(PROD_BASE_URL" "\)PROD_API_KEY"
          fi

      - name: Build Android artifact
        script: |
          if [ "$ENV" = "production" ]; then
            flutter build appbundle --release \
              --obfuscate \
              --split-debug-info=build/symbols
          else
            flutter build appbundle --release
          fi

      - name: Distribute to Firebase App Distribution
        script: |
          if [ "\(ENV" = "dev" ] || [ "\)ENV" = "staging" ]; then
            firebase appdistribution:distribute \
              build/app/outputs/bundle/release/app-release.aab \
              --app "$FIREBASE_ANDROID_APP_ID" \
              --groups "$FIREBASE_GROUPS" \
              --token "$FIREBASE_TOKEN"
          fi

      - name: Submit to Play Store
        script: |
          if [ "$ENV" = "production" ]; then
            echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" &gt; /tmp/service_account.json
            flutter pub global activate fastlane 2&gt;/dev/null || true
            fastlane supply \
              --aab build/app/outputs/bundle/release/app-release.aab \
              --json_key /tmp/service_account.json \
              --package_name com.your.package \
              --track production
          fi

      - name: Upload Sentry symbols
        script: |
          if [ "$ENV" = "production" ]; then
            ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
          fi

    artifacts:
      - build/app/outputs/bundle/release/app-release.aab
      - build/symbols/**

    publishing:
      email:
        recipients:
          - your-team@example.com
        notify:
          success: true
          failure: true
</code></pre>
<p>Here is what each section is doing and why it's designed this way.</p>
<p><code>android_signing</code></p>
<p>This is one of Codemagic's most valuable features. Instead of manually decoding a Base64 keystore and writing it to disk inside a script, you upload your keystore file directly to Codemagic's encrypted key storage under Teams → Code signing identities → Android keystores. You give it a reference name – <code>android_keystore</code> in this case – and Codemagic handles decoding, placement, and <code>key.properties</code> generation automatically before your build scripts run.</p>
<p>This eliminates an entire category of signing-related build failures.</p>
<p><code>groups</code></p>
<p>Codemagic lets you organize secrets into named groups in the environment variables section of your team settings. Rather than declaring individual secrets inline, you reference groups. The groups used here are:</p>
<ul>
<li><p><code>staging_secrets</code>: contains <code>STAGING_BASE_URL</code> and <code>STAGING_API_KEY</code></p>
</li>
<li><p><code>production_secrets</code>: contains <code>PROD_BASE_URL</code> and <code>PROD_API_KEY</code></p>
</li>
<li><p><code>firebase_credentials</code>: contains <code>FIREBASE_TOKEN</code>, <code>FIREBASE_ANDROID_APP_ID</code>, <code>FIREBASE_GROUPS</code></p>
</li>
<li><p><code>sentry_credentials</code>: contains <code>SENTRY_AUTH_TOKEN</code>, <code>SENTRY_ORG</code>, <code>SENTRY_PROJECT</code></p>
</li>
</ul>
<p><strong>Environment detection with</strong> <code>$CM_ENV</code></p>
<p>Codemagic exposes a special file path via the <code>$CM_ENV</code> variable. Writing <code>KEY=VALUE</code> to this file makes that variable available to every subsequent script step in the same build. This is how the branch name gets translated into an environment label that the rest of the pipeline reads.</p>
<p><strong>Build differentiation</strong></p>
<p>Production builds use <code>--obfuscate</code> and <code>--split-debug-info=build/symbols</code>. Dev and staging builds skip both flags for faster compilation and readable local stack traces.</p>
<p><strong>Firebase distribution</strong></p>
<p>The Firebase CLI distributes dev and staging builds to testers. Because Codemagic's Linux machines come with Node.js available, you can install the Firebase CLI with <code>npm install -g firebase-tools</code> as a setup step if it is not already present, or invoke it via <code>npx</code>.</p>
<p><strong>Play Store submission</strong></p>
<p>Production app bundles go to the Play Store using Fastlane's <code>supply</code> command. The service account JSON is written to a temporary file from the environment variable and passed to Fastlane directly. Replace <code>com.your.package</code> with your actual application ID.</p>
<p><code>artifacts</code></p>
<p>The artifacts section tells Codemagic which files to preserve after the build completes. These files become downloadable from the Codemagic build dashboard. The debug symbols are captured here as well, which is useful for manual Sentry uploads if the automated step ever needs to be re-run.</p>
<h2 id="heading-ios-pipeline">iOS Pipeline</h2>
<p>iOS on Codemagic is where the platform's advantage becomes most visible. Apple code signing on a general-purpose runner requires a multi-step keychain dance involving <code>security</code> commands, certificate imports, and provisioning profile placement. Codemagic handles all of that automatically through its native signing integration.</p>
<pre><code class="language-yaml">  ios-pipeline:
    name: iOS Build &amp; Release
    max_build_duration: 90
    instance_type: mac_mini_m2

    triggering:
      events:
        - push
      branch_patterns:
        - pattern: develop
          include: true
        - pattern: staging
          include: true
        - pattern: production
          include: true

    environment:
      flutter: stable
      ios_signing:
        distribution_type: app_store
        bundle_identifier: com.your.bundle.id
      groups:
        - staging_secrets
        - production_secrets
        - app_store_credentials
        - sentry_credentials

    scripts:
      - name: Install dependencies
        script: flutter pub get

      - name: Install Fastlane dependencies
        script: |
          cd ios
          gem install bundler --user-install
          bundle install

      - name: Detect environment
        script: |
          BRANCH=$(git rev-parse --abbrev-ref HEAD)
          if [ "$BRANCH" = "develop" ]; then
            echo "ENV=dev" &gt;&gt; $CM_ENV
          elif [ "$BRANCH" = "staging" ]; then
            echo "ENV=staging" &gt;&gt; $CM_ENV
          else
            echo "ENV=production" &gt;&gt; $CM_ENV
          fi

      - name: Generate environment config
        script: |
          if [ "$ENV" = "dev" ]; then
            ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"
          elif [ "$ENV" = "staging" ]; then
            ./scripts/generate_config.sh staging "\(STAGING_BASE_URL" "\)STAGING_API_KEY"
          else
            ./scripts/generate_config.sh production "\(PROD_BASE_URL" "\)PROD_API_KEY"
          fi

      - name: Build iOS (dev — no signing)
        script: |
          if [ "$ENV" = "dev" ]; then
            flutter build ios --release --no-codesign
          fi

      - name: Build and ship to TestFlight (staging)
        script: |
          if [ "$ENV" = "staging" ]; then
            flutter build ipa --release \
              --export-options-plist=/Users/builder/export_options.plist
            cd ios &amp;&amp; bundle exec fastlane beta
          fi

      - name: Build and release to App Store (production)
        script: |
          if [ "$ENV" = "production" ]; then
            flutter build ipa --release \
              --obfuscate \
              --split-debug-info=build/symbols \
              --export-options-plist=/Users/builder/export_options.plist
            cd ios &amp;&amp; bundle exec fastlane release
          fi

      - name: Upload Sentry symbols
        script: |
          if [ "$ENV" = "production" ]; then
            ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
          fi

    artifacts:
      - build/ios/ipa/*.ipa
      - build/symbols/**
      - /tmp/xcodebuild_logs/*.log

    publishing:
      app_store_connect:
        api_key: $APP_STORE_CONNECT_PRIVATE_KEY
        key_id: $APP_STORE_CONNECT_KEY_IDENTIFIER
        issuer_id: $APP_STORE_CONNECT_ISSUER_ID
        submit_to_testflight: true
        submit_to_app_store: false
      email:
        recipients:
          - your-team@example.com
        notify:
          success: true
          failure: true
</code></pre>
<p>Here's what is different from the Android workflow and why.</p>
<p><code>mac_mini_m2</code></p>
<p>iOS builds require Xcode, which means they need macOS. Codemagic provides Apple Silicon Mac Mini instances. These are meaningfully faster than Intel-based runners for Flutter and Xcode workloads, and Codemagic provisions them on demand without any infrastructure management on your side.</p>
<p><code>ios_signing</code></p>
<p>This is the section that replaces the entire keychain setup sequence. You upload your distribution certificate and provisioning profile once to Codemagic's code signing identities under your team settings. The <code>distribution_type: app_store</code> tells Codemagic to use App Store distribution signing, and <code>bundle_identifier</code> ties it to your specific app. Before your scripts run, Codemagic installs the certificate and profile automatically on the build machine.</p>
<p>No <code>security</code> commands, no keychain creation, no Base64 decoding. It's handled internally.</p>
<p><code>flutter build ipa</code></p>
<p>On iOS, the build output is an <code>.ipa</code> file rather than an <code>.aab</code>. Flutter's <code>flutter build ipa</code> command produces this directly when provided with an export options plist. The plist tells Xcode how to sign and package the output. Codemagic generates this file automatically based on your <code>ios_signing</code> configuration and places it at <code>/Users/builder/export_options.plist</code>.</p>
<p><strong>Fastlane lanes</strong></p>
<p>Codemagic installs Fastlane via Bundler in the <code>ios/</code> directory, then calls the appropriate lane based on the detected environment. The <code>beta</code> lane uploads to TestFlight, and the <code>release</code> lane submits to the App Store.</p>
<p><code>publishing.app_store_connect</code></p>
<p>Codemagic has a native App Store Connect publisher. Rather than scripting the upload manually, you declare your API credentials in the publishing block and Codemagic handles the submission. The <code>submit_to_testflight: true</code> flag means staging builds are automatically available to TestFlight testers after the build completes. For production, you would flip <code>submit_to_app_store</code> to <code>true</code> instead.</p>
<p><strong>Xcode logs as artifacts</strong></p>
<p>The line <code>/tmp/xcodebuild_logs/*.log</code> captures raw Xcode build logs as downloadable artifacts. When an iOS build fails and the error message in the Codemagic dashboard is not specific enough, these logs are where you find the real cause.</p>
<h2 id="heading-environment-variables-and-secrets-reference">Environment Variables and Secrets Reference</h2>
<p>All secrets are configured in Codemagic under Teams → Environment variables. Group them logically so they can be referenced cleanly in the YAML.</p>
<p><strong>staging_secrets group</strong></p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>STAGING_BASE_URL</code></td>
<td>Staging API base URL</td>
</tr>
<tr>
<td><code>STAGING_API_KEY</code></td>
<td>Staging API or encryption key</td>
</tr>
</tbody></table>
<p><strong>production_secrets group</strong></p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>PROD_BASE_URL</code></td>
<td>Production API base URL</td>
</tr>
<tr>
<td><code>PROD_API_KEY</code></td>
<td>Production API or encryption key</td>
</tr>
</tbody></table>
<p><strong>firebase_credentials group</strong></p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>FIREBASE_TOKEN</code></td>
<td>Generated via <code>firebase login:ci</code></td>
</tr>
<tr>
<td><code>FIREBASE_ANDROID_APP_ID</code></td>
<td>Android app ID from Firebase console</td>
</tr>
<tr>
<td><code>FIREBASE_GROUPS</code></td>
<td>Comma-separated tester group names</td>
</tr>
</tbody></table>
<p><strong>app_store_credentials group</strong></p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>APP_STORE_CONNECT_PRIVATE_KEY</code></td>
<td>Contents of the <code>.p8</code> key file from App Store Connect</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_KEY_IDENTIFIER</code></td>
<td>Key ID from App Store Connect</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_ISSUER_ID</code></td>
<td>Issuer ID from App Store Connect</td>
</tr>
<tr>
<td><code>GOOGLE_PLAY_SERVICE_ACCOUNT_JSON</code></td>
<td>Full JSON of your Play Console service account</td>
</tr>
</tbody></table>
<p><strong>sentry_credentials group</strong></p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>SENTRY_AUTH_TOKEN</code></td>
<td>Auth token from Sentry account settings</td>
</tr>
<tr>
<td><code>SENTRY_ORG</code></td>
<td>Your Sentry organization slug</td>
</tr>
<tr>
<td><code>SENTRY_PROJECT</code></td>
<td>Your Sentry project slug</td>
</tr>
</tbody></table>
<p>For Android code signing, upload your keystore directly under Teams → Code signing identities → Android keystores rather than storing it as an environment variable.</p>
<p>For iOS, upload your distribution certificate and provisioning profile under Teams → Code signing identities → iOS certificates.</p>
<h2 id="heading-end-to-end-flow">End-to-End Flow</h2>
<p>With the full <code>codemagic.yaml</code> in place, here is the complete picture of what happens across a typical release cycle.</p>
<p>A developer finishes a feature and raises a PR into <code>develop</code>. Codemagic detects the pull request event and triggers the <code>pr-quality-gate</code> workflow on a Linux machine. The quality checks script runs formatting, analysis, tests, and coverage threshold check. If anything fails, Codemagic marks the build as failed, sends the team an email, and the PR cannot be considered ready. The developer pushes a fix, Codemagic runs again, and only when everything passes does the PR move forward.</p>
<p>Once the PR merges into <code>develop</code>, both the <code>android-pipeline</code> and <code>ios-pipeline</code> trigger simultaneously. Each detects <code>develop</code> as the source branch, maps it to the dev environment, injects placeholder config, builds an unsigned release artifact, and ships it to Firebase App Distribution. Testers have an installable build within minutes of the merge completing.</p>
<p>When <code>develop</code> is merged into <code>staging</code>, the same two platform pipelines fire again. This time real secrets are injected , the staging API URL, the staging encryption key. Android builds are signed with the keystore Codemagic manages automatically. iOS builds go through Fastlane's <code>beta</code> lane to TestFlight. The Codemagic App Store Connect publisher handles the TestFlight upload natively. QA now has a properly signed, properly configured staging build to test against.</p>
<p>When <code>staging</code> is promoted to <code>production</code>, the pipelines enter release mode. Production secrets are injected. Android builds are obfuscated with debug symbols split into <code>build/symbols</code>. iOS builds go through <code>flutter build ipa</code> with obfuscation enabled. Both platform pipelines call <code>upload_symbols.sh</code> with the current commit SHA, linking the Sentry release to the exact code that shipped. The Android bundle goes to the Play Store via Fastlane. The iOS IPA is submitted to App Store Connect via Codemagic's native publisher. The team receives a success notification.</p>
<p>That's the full cycle. No terminal, no manual step, no shared Slack message saying "I think I deployed staging."</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The pipeline we just built covers the full release lifecycle: automated quality enforcement, environment-aware config injection, platform-specific signed builds, tester distribution, crash observability, and store submission , all from a single <code>codemagic.yaml</code> file.</p>
<p>What Codemagic brings to this setup is a tighter integration with the mobile ecosystem specifically. The keystore management, native App Store Connect publisher, pre-installed Flutter toolchain, and Apple Silicon Mac instances aren't add-ons you configure , they're part of the platform's core. This translates into fewer steps to maintain, fewer failure surfaces, and a pipeline that's easier to reason about when something does go wrong.</p>
<p>The scripts in your <code>scripts/</code> folder remain completely platform-agnostic. If your team ever needs to move pipelines, those scripts move with you unchanged. The YAML changes, but the logic doesn't.</p>
<p>What you have at the end of this setup is a release process your team can trust: one where "did it deploy?" is answered by a notification, not a question in Slack.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Production-Ready Flutter CI/CD Pipeline with GitHub Actions: Quality Gates, Environments, and Store Deployment ]]>
                </title>
                <description>
                    <![CDATA[ Mobile application development has evolved over the years. The processes, structure, and syntax we use has changed, as well as the quality and flexibility of the apps we build. One of the major improv ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-production-ready-flutter-ci-cd-pipeline-with-github-actions-quality-gates-environments-and-store-deployment/</link>
                <guid isPermaLink="false">69bb2e078c55d6eefb6c2e8d</guid>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ github-actions ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ github copilot ]]>
                    </category>
                
                    <category>
                        <![CDATA[ CI/CD pipelines ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Productivity ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Wed, 18 Mar 2026 22:58:15 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8c9d9384-ff02-47d7-aa69-42db2ebae247.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Mobile application development has evolved over the years. The processes, structure, and syntax we use has changed, as well as the quality and flexibility of the apps we build.</p>
<p>One of the major improvements has been a properly automated CI/CD pipeline flow that gives us seamless automation, continuous integration, and continuous deployment.</p>
<p>In this article, I'll break down how you can automate and build a production ready CI/CD pipeline for your Flutter application using GitHub Actions.</p>
<p>Note that there are other ways to do this, like with Codemagic (built specifically for Flutter apps – which I'll cover in a subsequent tutorial), but in this article we'll focus on GitHub Actions instead.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-the-typical-workflow">The Typical Workflow</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-pipeline-architecture">Pipeline Architecture</a></p>
</li>
<li><p><a href="#heading-writing-the-workflows">Writing the Workflows</a></p>
<ul>
<li><p><a href="#heading-the-helper-scripts">The Helper Scripts</a></p>
<ul>
<li><p><a href="#heading-script-1-generateconfigsh">generate_config.sh</a></p>
</li>
<li><p><a href="#heading-script-2-qualitygatesh">quality_gate.sh</a></p>
</li>
<li><p><a href="#heading-script-3-uploadsymbolssh-sentry">upload_symbols.sh</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-workflow-1-prchecksyml">PR Quality Gate (pr_checks.yml)</a></p>
</li>
<li><p><a href="#heading-workflow-2-androidyml">Android CI/CD Pipeline (android.yml)</a></p>
</li>
<li><p><a href="#heading-workflow-3-iosyml">iOS CI/CD Pipeline (ios.yml)</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-secrets-and-configuration-reference">Secrets and Configuration Reference</a></p>
</li>
<li><p><a href="#heading-end-to-end-flow">End-to-End Flow</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-the-typical-workflow">The Typical Workflow</h2>
<p>First, let's define the common approach to deploying production-ready Flutter apps.</p>
<p>The development team does their work on local, pushes to the repository for merge or review, and eventually runs <code>flutter build apk</code> or <code>flutter build appbundle</code> to generate the apk file. This then gets shared with the QA team manually, or deployed to Firebase app distribution for testing. If it's a production move, the app bundle is submitted to the Google Play store for review and then deployed.</p>
<p>This process is often fully manual with no automated checks, validation, or control over quality, speed, and seamlessness. Manually shipping a Flutter app starts out relatively simply, but can quickly and quietly turn into a liability. You run <code>flutter build</code>, switch configs, sign the build, upload it somewhere, and hope you didn’t mix up staging keys with production ones.</p>
<p>As teams grow and release updates more and more quickly, these manual steps become real risks. A skipped quality check, a missing keystore, or an incorrect base URL deployed to production can cost hours of debugging or worse – it can affect your users.</p>
<p>Automating this process fully involves some high level configuration and predefined scripting. It completely takes control of the deployment process from the moment the developer raised a PR into the common or base branch (for example, the <code>develop</code> branch).</p>
<p>This automated process takes care of everything that needs to be done – provided it has been predefined, properly scripted, and aligns with the use case of the team.</p>
<h3 id="heading-what-well-do-here">What we'll do here:</h3>
<p>In this tutorial, we'll build a production-grade CI/CD pipeline for a Flutter app using GitHub Actions. The pipeline automates the entire lifecycle: pull-request quality checks, environment-specific configuration injection, Android and iOS builds, Firebase App Distribution for testers, Sentry symbol uploads, and final deployment to the Play Store and App Store.</p>
<p>By the end, every release – from a developer opening a PR to the final build landing in users' hands – will be fully automated, with no one touching a terminal.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before starting, you should have:</p>
<ol>
<li><p>A Flutter app with working Android and iOS builds</p>
</li>
<li><p>Basic familiarity with <a href="https://www.freecodecamp.org/news/automate-cicd-with-github-actions-streamline-workflow/">GitHub Actions</a> (workflows and jobs)</p>
</li>
<li><p>A Firebase project with App Distribution enabled</p>
</li>
<li><p>A Sentry project for error tracking</p>
</li>
<li><p>A Google Play Console app already created</p>
</li>
<li><p>An Apple Developer account with App Store Connect access</p>
</li>
<li><p>Fastlane configured for your iOS project</p>
</li>
<li><p>Basic Bash knowledge (I’ll explain the important parts)</p>
</li>
</ol>
<h2 id="heading-pipeline-architecture">Pipeline Architecture</h2>
<p>In this guide, we'll be building a CI/CD pipeline with very precise instructions and use cases. These use cases determine the way your pipeline is built.</p>
<p>For this tutorial, we'll use this use case:</p>
<p>I want to automate the workflow on my development team based on the following criteria:</p>
<ol>
<li><p>When a developer on the team raises a PR into the common working branch <code>develop</code> in most cases), a workflow is triggered to run quality checks on the code. It only allows the merge to happen if all checks (like tests coverage, quality checks, and static analysis) pass.</p>
</li>
<li><p>Code that's moving from the develop branch to the staging branch goes through another workflow that injects staging configurations/secret keys, does all the necessary checks, and distributes the application for testing on Firebase App Distribution for android as well as Testflight for iOS.</p>
</li>
<li><p>Code that's moving from the staging to the production branch goes through the production level workflow which involves apk secured signing, production configuration injection, running tests to ensure nothing breaks, Sentry analysis for monitoring, and submission to App Store Connect as well as Google Play Console.</p>
</li>
</ol>
<p>These are our predefined conditions which help with the construction of our workflows.</p>
<h2 id="heading-writing-the-workflows">Writing the Workflows</h2>
<p>We'll split this pipeline into three GitHub Actions workflows.</p>
<p>We'll also be taking it a notch higher by creating three helper .sh scripts for a cleaner and more maintainable workflow.</p>
<p>In your project root, create two folders:</p>
<ol>
<li><p>.github/</p>
</li>
<li><p>scripts.</p>
</li>
</ol>
<p>The <strong>.github/</strong> folder will hold the workflows we'll be creating for each use case, while the <strong>scripts/</strong> folder will hold the helper scripts that we can easily call in our CLI or in the workflows directly.</p>
<p>After this, we'll create three workflow .yaml files:</p>
<ol>
<li><p>pr_checks.yaml</p>
</li>
<li><p>android.yaml</p>
</li>
<li><p>ios.yaml</p>
</li>
</ol>
<p>Also in the scripts folder, let's create three .sh files:</p>
<ol>
<li><p>generate_config.sh</p>
</li>
<li><p>quality_checks.sh</p>
</li>
<li><p>upload_symbols.sh</p>
</li>
</ol>
<pre><code class="language-yaml">.github/
  workflows/
    pr_checks.yml
    android.yml
    ios.yml

scripts/
  generate_config.sh
  quality_checks.sh
  upload_symbols.sh
</code></pre>
<p>This workflow architecture ensures that a push to <code>develop</code> automatically produces a tester build. Also, merging to <code>production</code> ships directly to the stores without manual commands or config changes.</p>
<p>The scripts live outside the YAML on purpose. This lets you run the same logic locally.</p>
<h3 id="heading-the-helper-scripts">The Helper Scripts</h3>
<p>The scripts form the backbone of the pipeline. Each one has a single responsibility and is reused across workflows.</p>
<p>Instead of cramming logic into YAML, we'll move it into <strong>reusable scripts</strong>. This keeps workflows clean and lets you run the same logic locally. Let's go through each one now.</p>
<h3 id="heading-script-1-generateconfigsh">Script #1: <code>generate_config.sh</code></h3>
<p>Injecting secrets safely is one of the hardest CI/CD problems in mobile apps.</p>
<p>The strategy:</p>
<ul>
<li><p>Commit a Dart template file with placeholders</p>
</li>
<li><p>Replace placeholders at build time using secrets from GitHub Actions</p>
</li>
<li><p>Never commit real credentials</p>
</li>
</ul>
<pre><code class="language-yaml">#!/usr/bin/env bash
set -euo pipefail


ENV_NAME=${1:-}
BASE_URL=${2:-}
ENCRYPTION_KEY=${3:-}

TEMPLATE="lib/core/env/env_ci.dart"
OUT="lib/core/env/env_ci.g.dart"

if [ -z "\(ENV_NAME" ] || [ -z "\)BASE_URL" ] || [ -z "$ENCRYPTION_KEY" ]; then
  echo "Usage: $0 &lt;env-name&gt; &lt;base-url&gt; &lt;encryption-key&gt;"
  exit 2
fi

sed -e "s|&lt;&lt;BASE_URL&gt;&gt;|$BASE_URL|g" \
    -e "s|&lt;&lt;ENCRYPTION_KEY&gt;&gt;|$ENCRYPTION_KEY|g" \
    -e "s|&lt;&lt;ENV_NAME&gt;&gt;|$ENV_NAME|g" \
    "\(TEMPLATE" &gt; "\)OUT"

echo "Generated config for $ENV_NAME"
</code></pre>
<p>This script is responsible for injecting environment-specific configuration into the Flutter app at build time, without ever committing secrets to source control.</p>
<p>Let’s walk through it carefully.</p>
<h4 id="heading-1-shebang-choosing-the-shell">1. Shebang: Choosing the Shell</h4>
<pre><code class="language-yaml">#!/usr/bin/env bash
</code></pre>
<p>This line tells the system to execute the script using <strong>Bash</strong>, regardless of where Bash is installed on the machine.</p>
<p>Using <code>/usr/bin/env bash</code> instead of <code>/bin/bash</code> makes the script more portable across local machines, GitHub Actions runners, and Docker containers.</p>
<h4 id="heading-2-fail-fast-fail-loud">2. Fail Fast, Fail Loud</h4>
<pre><code class="language-yaml">set -euo pipefail
</code></pre>
<p>This is one of the most important lines in the script.</p>
<p>It enables three strict Bash modes:</p>
<ul>
<li><p><code>-e</code>: Exit immediately if any command fails</p>
</li>
<li><p><code>-u</code>: Exit if an undefined variable is used</p>
</li>
<li><p><code>-o pipefail</code>: Fail if any command in a pipeline fails, not just the last one</p>
</li>
</ul>
<p>This matters in CI because silent failures are dangerous, partial config generation can break production builds, and CI should stop immediately when something is wrong.</p>
<p>This line ensures that no broken config ever makes it into a build.</p>
<h4 id="heading-3-reading-input-arguments">3. Reading Input Arguments</h4>
<pre><code class="language-yaml">
ENV_NAME=${1:-}
BASE_URL=${2:-}
ENCRYPTION_KEY=${3:-}
</code></pre>
<p>These lines read <strong>positional arguments</strong> passed to the script:</p>
<ul>
<li><p><code>$1</code>: Environment name (<code>dev</code>, <code>staging</code>, <code>production</code>)</p>
</li>
<li><p><code>$2</code>: API base URL</p>
</li>
<li><p><code>$3</code>: Encryption or API key</p>
</li>
</ul>
<p>The <code>${1:-}</code> syntax means:</p>
<p><em>“If the argument is missing, default to an empty string instead of crashing.”</em></p>
<p>This works hand-in-hand with <code>set -u</code> , we control the failure explicitly instead of letting Bash explode unexpectedly.</p>
<h4 id="heading-4-defining-input-and-output-files">4. Defining Input and Output Files</h4>
<pre><code class="language-yaml">TEMPLATE="lib/core/env/env_ci.dart"
OUT="lib/core/env/env_ci.g.dart"
</code></pre>
<p>Here we define two files:</p>
<ul>
<li><p><strong>Template file (</strong><code>env_ci.dart</code><strong>)</strong></p>
<ul>
<li><p>Contains placeholder values like <code>&lt;&lt;BASE_URL&gt;&gt;</code></p>
</li>
<li><p>Safe to commit to Git</p>
</li>
</ul>
</li>
<li><p><strong>Generated file (</strong><code>env_ci.g.dart</code><strong>)</strong></p>
<ul>
<li><p>Contains real environment values</p>
</li>
<li><p>Must be ignored by Git (<code>.gitignore</code>)</p>
</li>
</ul>
</li>
</ul>
<p>At the heart of this approach are two Dart files with very different responsibilities. They may look similar, but they play completely different roles in the system.</p>
<h4 id="heading-envcidart"><code>env.ci.dart</code>:</h4>
<pre><code class="language-java">// lib/core/env/env_ci.dart

class EnvConfig {
  static const String baseUrl = '&lt;&lt;BASE_URL&gt;&gt;';
  static const String encryptionKey = '&lt;&lt;ENCRYPTION_KEY&gt;&gt;';
  static const String environment = '&lt;&lt;ENV_NAME&gt;&gt;';
}
</code></pre>
<p>This file is <strong>safe</strong>, <strong>static</strong>, and <strong>version-controlled</strong>. It contains placeholders, not real values.</p>
<p>Some of its key characteristics are:</p>
<ul>
<li><p>Contains no real secrets</p>
</li>
<li><p>Uses obvious placeholders (<code>&lt;&lt;BASE_URL&gt;&gt;</code>, etc.)</p>
</li>
<li><p>Safe to commit to Git</p>
</li>
<li><p>Reviewed like normal source code</p>
</li>
<li><p>Serves as the single source of truth for required config fields</p>
</li>
</ul>
<p>Think of this file as a contract:</p>
<p><em>“These are the configuration values the app expects at runtime.”</em></p>
<h4 id="heading-envcigdart"><code>env.ci.g.dart</code>:</h4>
<p>This file is created at <strong>build time</strong> by <code>generate_config.sh</code>. After substitution, it looks like this:</p>
<pre><code class="language-java">// lib/core/env/env_ci.g.dart
// GENERATED FILE — DO NOT COMMIT

class EnvConfig {
  static const String baseUrl = 'https://staging.api.example.com';
  static const String encryptionKey = 'sk_live_xxxxx';
  static const String environment = 'staging';
}
</code></pre>
<p>Key characteristics:</p>
<ul>
<li><p>Contains real environment values</p>
</li>
<li><p>Generated dynamically in CI</p>
</li>
<li><p>Differs per environment (dev / staging / production)</p>
</li>
<li><p>Must <strong>never</strong> be committed to source control</p>
</li>
</ul>
<p>This file exists only on a developer’s machine (if generated locally), inside the CI runner during a build. Once the job finishes, it disappears.</p>
<h4 id="heading-gitignore"><code>.gitignore</code>:</h4>
<p>To guarantee the generated file never leaks, it must be ignored:</p>
<h4 id="heading-why-this-separation-is-critical">Why This Separation Is Critical</h4>
<p>This design solves several hard problems at once.</p>
<p><strong>Security:</strong></p>
<ul>
<li><p>Secrets live <strong>only</strong> in GitHub Actions secrets</p>
</li>
<li><p>They never appear in the repository</p>
</li>
<li><p>They never appear in PRs</p>
</li>
<li><p>They never appear in Git history</p>
</li>
</ul>
<p><strong>Environment Isolation:</strong></p>
<p>Each environment gets its own generated config:</p>
<ul>
<li><p><code>develop</code>: dev API</p>
</li>
<li><p><code>staging</code>: staging API</p>
</li>
<li><p><code>production</code>: production API</p>
</li>
</ul>
<p>The same codebase behaves differently <strong>without branching logic in Dart</strong>.</p>
<p><strong>Deterministic Builds:</strong></p>
<p>Every build is fully reproducible, fully automated, and explicit about which environment it targets.</p>
<p>There are no “it worked locally” scenarios.</p>
<h4 id="heading-5-validating-required-arguments">5. Validating Required Arguments</h4>
<pre><code class="language-java">if [ -z "\(ENV_NAME" ] || [ -z "\)BASE_URL" ] || [ -z "$ENCRYPTION_KEY" ]; then
  echo "Usage: $0 &lt;env-name&gt; &lt;base-url&gt; &lt;encryption-key&gt;"
  exit 2
fi
</code></pre>
<p>This block enforces correct usage.</p>
<ul>
<li><p><code>-z</code> checks whether a variable is empty</p>
</li>
<li><p>If any required argument is missing:</p>
<ul>
<li><p>A helpful usage message is printed</p>
</li>
<li><p>The script exits with a non-zero status code</p>
</li>
</ul>
</li>
<li><p><code>0</code>: success</p>
</li>
<li><p><code>1+</code>: failure</p>
</li>
<li><p><code>2</code> conventionally means incorrect usage</p>
</li>
</ul>
<p>In CI, this immediately fails the job and prevents an invalid build.</p>
<h4 id="heading-6-injecting-environment-values">6. Injecting Environment Values</h4>
<pre><code class="language-java">sed -e "s|&lt;&lt;BASE_URL&gt;&gt;|$BASE_URL|g" \
    -e "s|&lt;&lt;ENCRYPTION_KEY&gt;&gt;|$ENCRYPTION_KEY|g" \
    -e "s|&lt;&lt;ENV_NAME&gt;&gt;|$ENV_NAME|g" \
    "\(TEMPLATE" &gt; "\)OUT"
</code></pre>
<p>This is the heart of the script.</p>
<p>What’s happening here:</p>
<ol>
<li><p><code>sed</code> performs <strong>stream editing</strong>: it reads text, transforms it, and outputs the result</p>
</li>
<li><p>Each <code>-e</code> flag defines a replacement rule:</p>
<ul>
<li><p>Replace <code>&lt;&lt;BASE_URL&gt;&gt;</code> with the actual API URL</p>
</li>
<li><p>Replace <code>&lt;&lt;ENCRYPTION_KEY&gt;&gt;</code> with the real key</p>
</li>
<li><p>Replace <code>&lt;&lt;ENV_NAME&gt;&gt;</code> with the environment label</p>
</li>
</ul>
</li>
<li><p>The transformed output is written to <code>env_ci.g.dart</code></p>
</li>
</ol>
<p>This entire operation happens <strong>at build time</strong>:</p>
<ul>
<li><p>No secrets are committed</p>
</li>
<li><p>No secrets are logged</p>
</li>
<li><p>No secrets persist beyond the CI run</p>
</li>
</ul>
<h4 id="heading-7-success-feedback">7. Success Feedback</h4>
<pre><code class="language-java">echo "Generated config for $ENV_NAME"
</code></pre>
<p>This line provides a clear success signal in CI logs.</p>
<p>It answers three important questions instantly:</p>
<ul>
<li><p>Did the script run?</p>
</li>
<li><p>Did it finish successfully?</p>
</li>
<li><p>Which environment was generated?</p>
</li>
</ul>
<p>In long CI logs, these small confirmations matter.</p>
<p>Alright, now let's move on to the second script.</p>
<h3 id="heading-script-2-qualitygatesh">Script #2: <code>quality_gate.sh</code></h3>
<p>This script defines what <em>“good code”</em> means for your team.</p>
<pre><code class="language-yaml">#!/usr/bin/env bash
set -euo pipefail

echo "Running quality checks"

dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test --no-pub --coverage

if command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1; then
  dart_code_metrics analyze lib --reporter=console || true
fi

echo "Quality checks passed"
</code></pre>
<p>Lets break down this script bit by bit.</p>
<h4 id="heading-1-start-amp-end-log-markers">1. Start &amp; End Log Markers</h4>
<pre><code class="language-yaml">echo "Running quality checks"
...
echo "Quality checks passed"
</code></pre>
<p>These two lines act as <strong>visual boundaries</strong> in CI logs.</p>
<p>In large pipelines (especially when Android and iOS jobs run in parallel), logs can be very noisy. Clear markers:</p>
<ul>
<li><p>Help developers quickly find the quality phase</p>
</li>
<li><p>Make debugging faster</p>
</li>
<li><p>Confirm that the script completed successfully</p>
</li>
</ul>
<p>The final success message only prints if <strong>everything above it passed</strong>, because <code>set -e</code> would have terminated the script earlier on failure.</p>
<p>So this line effectively means: All quality gates passed. Safe to proceed.</p>
<h4 id="heading-2-running-the-test-suite">2. Running the Test Suite</h4>
<pre><code class="language-yaml">flutter test --no-pub --coverage
</code></pre>
<p>This line executes your entire Flutter test suite.</p>
<p>Let’s break it down carefully.</p>
<p>1. <code>flutter test</code></p>
<p>This runs unit tests, widget tests, and any test under the <code>test/</code> directory. If <strong>any test fails</strong>, the command exits with a non-zero status code.</p>
<p>Because we enabled <code>set -e</code> earlier, that immediately stops the script and fails the CI job.</p>
<p>2. <code>--coverage</code></p>
<p>This flag generates a coverage report at:</p>
<pre><code class="language-yaml">coverage/lcov.info
</code></pre>
<p>This file can later be uploaded to Codecov, used to enforce minimum coverage thresholds, and tracked over time for quality improvement.</p>
<p>Even if you’re not enforcing coverage yet, generating it now future-proofs your pipeline.</p>
<h4 id="heading-3-optional-code-metrics">3. Optional Code Metrics</h4>
<pre><code class="language-yaml">if command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1; then
  dart_code_metrics analyze lib --reporter=console || true
fi
</code></pre>
<p>This block is intentionally designed to be optional and non-blocking.</p>
<p><strong>Step 1 – Check If the Tool Exists:</strong></p>
<pre><code class="language-yaml">command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1
</code></pre>
<p>This checks whether <code>dart_code_metrics</code> is installed.</p>
<ul>
<li><p>If installed, proceed</p>
</li>
<li><p>If not installed, skip silently</p>
</li>
</ul>
<p>The redirection:</p>
<ul>
<li><p><code>&gt;/dev/null</code> hides normal output</p>
</li>
<li><p><code>2&gt;&amp;1</code> hides errors</p>
</li>
</ul>
<p>This makes the script portable:</p>
<ul>
<li><p>Developers without the tool can still run the script</p>
</li>
<li><p>CI can enforce it if configured</p>
</li>
</ul>
<p><strong>Step 2 – Run Metrics (Soft Enforcement):</strong></p>
<pre><code class="language-yaml">dart_code_metrics analyze lib --reporter=console || true
</code></pre>
<p>This analyzes the <code>lib/</code> directory and prints results in the console.</p>
<p>The important part is:</p>
<pre><code class="language-yaml">|| true
</code></pre>
<p>Because we enabled <code>set -e</code>, any failing command would normally stop the script.</p>
<p>Adding <code>|| true</code> overrides that behavior:</p>
<ul>
<li><p>If metrics report issues,</p>
</li>
<li><p>The script continues,</p>
</li>
<li><p>CI does not fail.</p>
</li>
</ul>
<p>Why design it this way? Because metrics are often gradual improvements, technical debt indicators, or advisory rather than blocking.</p>
<p>You can later remove <code>|| true</code> to make metrics mandatory.</p>
<h4 id="heading-4-final-success-message"><strong>4. Final Success Message</strong></h4>
<pre><code class="language-yaml">echo "✅ Quality checks passed"
</code></pre>
<p>This line only executes if formatting passed, static analysis passed, and tests passed.</p>
<p>If you see this in CI logs, it means the branch has successfully cleared the quality gate. It’s your automated approval before deployment steps begin.</p>
<h4 id="heading-what-this-script-guarantees">What This Script Guarantees</h4>
<p>With this in place, every branch must satisfy:</p>
<ul>
<li><p>Clean formatting</p>
</li>
<li><p>No analyzer errors</p>
</li>
<li><p>Passing tests</p>
</li>
<li><p>(Optional) Healthy metrics</p>
</li>
</ul>
<p>That’s how you move from <strong>“We try to maintain quality”</strong> to <strong>“Quality is enforced automatically.”</strong></p>
<p>Alright, on to the third script.</p>
<h3 id="heading-script-3-uploadsymbolssh-sentry"><strong>Script #3:</strong> <code>upload_symbols.sh</code> <strong>(Sentry)</strong></h3>
<p>This script is responsible for uploading <strong>obfuscation debug symbols</strong> to Sentry so production crashes remain readable.</p>
<pre><code class="language-yaml">#!/usr/bin/env bash
set -euo pipefail

RELEASE=${1:-}

[ -z "$RELEASE" ] &amp;&amp; exit 2

if ! command -v sentry-cli &gt;/dev/null 2&gt;&amp;1; then
  exit 0
fi

sentry-cli releases new "$RELEASE" || true

sentry-cli upload-dif build/symbols || true

sentry-cli releases finalize "$RELEASE" || true

echo "✅ Symbols uploaded for release $RELEASE"
</code></pre>
<p>Let's go through it step by step.</p>
<h4 id="heading-1-reading-the-release-identifier">1. Reading the Release Identifier</h4>
<pre><code class="language-yaml">RELEASE=${1:-}
</code></pre>
<p>This reads the first positional argument passed to the script.</p>
<p>When you call the script in CI, it typically looks like:</p>
<pre><code class="language-yaml">./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
</code></pre>
<p>So <code>$1</code> becomes the short Git commit SHA.</p>
<p>Using <code>${1:-}</code> ensures:</p>
<ul>
<li><p>If no argument is passed, the variable becomes an empty string</p>
</li>
<li><p>The script does not crash due to <code>set -u</code></p>
</li>
</ul>
<p>This release value ties the uploaded symbols, deployed build, and crash reports all to the exact same commit. This linkage is critical for production debugging.</p>
<h4 id="heading-2-validating-the-release-argument">2. Validating the Release Argument</h4>
<pre><code class="language-yaml">[ -z "$RELEASE" ] &amp;&amp; exit 2
</code></pre>
<p>This is a compact validation check.</p>
<ul>
<li><p><code>-z</code> checks whether the string is empty</p>
</li>
<li><p>If it is empty → exit with status code 2</p>
</li>
</ul>
<p>Conventionally:</p>
<ul>
<li><p><code>0</code> = success</p>
</li>
<li><p><code>1+</code> = failure</p>
</li>
<li><p><code>2</code> = incorrect usage</p>
</li>
</ul>
<p>This prevents symbol uploads from running without a release identifier, which would break traceability in Sentry.</p>
<h4 id="heading-3-checking-if-sentry-cli-exists">3. Checking If <code>sentry-cli</code> Exists</h4>
<pre><code class="language-yaml">if ! command -v sentry-cli &gt;/dev/null 2&gt;&amp;1; then
  exit 0
fi
</code></pre>
<p>This block checks whether the <code>sentry-cli</code> tool is available in the environment.</p>
<p>What’s happening:</p>
<ul>
<li><p><code>command -v sentry-cli</code> checks if it exists</p>
</li>
<li><p><code>&gt;/dev/null 2&gt;&amp;1</code> suppresses all output</p>
</li>
<li><p><code>!</code> negates the condition</p>
</li>
</ul>
<p>So this reads as: <em>"If</em> <code>sentry-cli</code> <em>is NOT installed, exit successfully."</em></p>
<p>Why exit with <code>0</code> instead of failing?</p>
<p>Because not every environment needs symbol uploads. Also, dev builds may not install Sentry, and you don’t want CI to fail just because Sentry isn’t configured.</p>
<p>This makes symbol uploading <strong>environment-aware</strong> and <strong>optional</strong>.</p>
<p>Production environments can install <code>sentry-cli</code>, while dev environments skip it cleanly.</p>
<h4 id="heading-4-creating-a-new-release-in-sentry">4. Creating a New Release in Sentry</h4>
<pre><code class="language-yaml">sentry-cli releases new "$RELEASE" || true
</code></pre>
<p>This tells Sentry: “A new release exists with this version identifier.”</p>
<p>Even if the release already exists, the script continues because of:</p>
<pre><code class="language-yaml">|| true
</code></pre>
<p>This prevents the build from failing if:</p>
<ul>
<li><p>The release was already created</p>
</li>
<li><p>The command returns a non-critical error</p>
</li>
</ul>
<p>The goal is resilience, not strict enforcement.</p>
<h4 id="heading-5-uploading-debug-information-files-difs">5. Uploading Debug Information Files (DIFs)</h4>
<pre><code class="language-yaml">sentry-cli upload-dif build/symbols || true
</code></pre>
<p>This is the core step.</p>
<p><code>build/symbols</code> is generated when you build Flutter with:</p>
<pre><code class="language-yaml">--obfuscate --split-debug-info=build/symbols
</code></pre>
<p>When you obfuscate Flutter builds:</p>
<ul>
<li><p>Method names are renamed</p>
</li>
<li><p>Stack traces become unreadable</p>
</li>
</ul>
<p>The symbol files allow Sentry to reverse-map obfuscated stack traces and show readable crash reports.</p>
<p>Without this step, production crashes look like:</p>
<pre><code class="language-yaml">a.b.c.d (Unknown Source)
</code></pre>
<p>With this step, you get:</p>
<pre><code class="language-yaml">AuthRepository.login()
</code></pre>
<p>Again, <code>|| true</code> ensures the build doesn’t fail if:</p>
<ul>
<li><p>The directory doesn’t exist</p>
</li>
<li><p>No symbols were generated</p>
</li>
<li><p>Upload encounters a transient issue</p>
</li>
</ul>
<p>Symbol uploads should not block deployment.</p>
<h4 id="heading-6-finalizing-the-release">6. Finalizing the Release</h4>
<pre><code class="language-yaml">sentry-cli releases finalize "$RELEASE" || true
</code></pre>
<p>This marks the release as complete in Sentry.</p>
<p>Finalizing signals:</p>
<ul>
<li><p>The release is deployed</p>
</li>
<li><p>It can begin aggregating crash reports</p>
</li>
<li><p>It’s ready for production monitoring</p>
</li>
</ul>
<p>Like the previous steps, this is soft-failed with <code>|| true</code> to keep CI robust.</p>
<h4 id="heading-what-this-script-guarantees">What This Script Guarantees</h4>
<p>When everything is configured correctly:</p>
<ol>
<li><p>Production build is obfuscated</p>
</li>
<li><p>Debug symbols are generated</p>
</li>
<li><p>Symbols are uploaded to Sentry</p>
</li>
<li><p>Crashes map back to real source code</p>
</li>
<li><p>Release version matches commit SHA</p>
</li>
</ol>
<p>That’s production-grade crash observability.</p>
<p>Now that we've gone through the three helper scripts we've created to optimize and enhance this process, lets now dive into the three workflow .yaml files we're going to create.</p>
<h2 id="heading-workflow-1-prchecksyml">Workflow #1: <code>PR_CHECKS.YML</code></h2>
<p>This workflow will be designed to help ensure a certain standard is met once a PR is raised into a certain common or base branch. This will ensure that all quality checks in the incoming code pass before allowing any merge into the base branch.</p>
<p>This is basically a gate that verifies the quality of the code that's about to be merged into the base branch. If your pipeline allows unverified code into your base branch, then your CI becomes decorative, not protective.</p>
<p>Lets break down what's actually needed during every PR Check.</p>
<h3 id="heading-1-dependency-integrity">1. Dependency Integrity</h3>
<p>For Flutter apps, where we manage dependencies with the <strong>pub get</strong> command, it's important to make sure that the integrity of all dependencies are confirmed – up to date as well as compatible.</p>
<p>Every PR should begin with:</p>
<pre><code class="language-yaml">flutter pub get
</code></pre>
<p>This ensures:</p>
<ul>
<li><p><code>pubspec.yaml</code> is valid</p>
</li>
<li><p>Dependency constraints are consistent</p>
</li>
<li><p>Lockfiles are not broken</p>
</li>
<li><p>The project is buildable in a clean environment</p>
</li>
</ul>
<p>If this fails, the branch is not deployable.</p>
<h3 id="heading-2-static-analysis">2. Static Analysis</h3>
<p>This ensures code quality and architecture integrity. Static analysis helps prevent common issues like forgotten await, dead code, null safety violations, async misuse, and so on.</p>
<p>Most production bugs aren't business logic errors – they're structural carelessness. Static analysis helps enforce consistency automatically, so code reviews focus on intent, not linting.</p>
<pre><code class="language-yaml">flutter analyze --fatal-infos --fatal-warnings
</code></pre>
<h3 id="heading-3-formatting">3. Formatting</h3>
<p>This command ensures that your code is properly formatted based on your organization's coding standard and policies.</p>
<pre><code class="language-yaml">dart format --output=none --set-exit-if-changed .
</code></pre>
<h3 id="heading-4-tests">4. Tests</h3>
<p>This runs the unit, widget and business logic tests to ensure quality and avoid regression leaks, silent behavior changes and feature drift.</p>
<pre><code class="language-yaml">flutter test --coverage
</code></pre>
<h3 id="heading-5-test-coverage-enforcement">5. Test Coverage Enforcement</h3>
<p>Ideally, running tests is not enough. Your workflow should also enforce a minimum threshold:</p>
<pre><code class="language-yaml">if [ \((lcov --summary coverage/lcov.info | grep lines | awk '{print \)2}' | sed 's/%//') -lt 70 ]; then
  echo "Coverage too low"
  exit 1
fi
</code></pre>
<p>The command above ensures that a minimum test coverage of 70% is met, with this quality becomes measurable.</p>
<p>The five commands above must be checked (at least) for a <strong>quality gate</strong> to guarantee code quality, security, and integrity.</p>
<p>Now here is the full <strong>pr_checks.yml</strong> file:</p>
<pre><code class="language-yaml">name: PR Quality Gate

on:
  pull_request:
    branches: develop
    types: [opened, synchronize, reopened, ready_for_review]

jobs:
  pr-checks:
    name: Run quality checks on this pull request
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Setup Java
        uses: actions/setup-java@v1
        with:
          java-version: "12.x"

      - name: Setup Flutter
        uses: subosito/flutter-action@v1
        with:
          channel: "stable"

      - name: Install dependencies
        run: flutter pub get

      - name: Run quality checks
        run: ./scripts/quality_checks.sh

      - name: Notify Team (Success)
        if: success()
        run: |
          echo "PR Quality Checks PASSED"
          echo "PR: ${{ github.event.pull_request.html_url }}"
          echo "Branch: \({{ github.head_ref }} → \){{ github.base_ref }}"
          echo "By: @${{ github.actor }}"
          echo "Team notification: @foluwaseyi-dev @olabodegbolu"

      - name: Notify Team (Failure)
        if: failure()
        run: |
          echo "PR Quality Checks FAILED"
          echo "PR: ${{ github.event.pull_request.html_url }}"
          echo "Branch: \({{ github.head_ref }} → \){{ github.base_ref }}"
          echo "By: @${{ github.actor }}"
          echo "Please fix the issues before requesting review 🔧"
          echo "Team notification: @foluwaseyi-dev @olabodegbolu"
</code></pre>
<p>Every time a developer opens (or updates) a pull request targeting the <code>develop</code> branch, this workflow kicks in automatically. Think of it as a bouncer at the door: no code gets through without passing inspection first.</p>
<h3 id="heading-what-triggers-it">What Triggers it?</h3>
<p>The workflow fires on four events: when a PR is <code>opened</code>, <code>synchronized</code> (new commits pushed), <code>reopened</code>, or marked <code>ready_for_review</code>. So drafts won't trigger it – only PRs that are actually ready to be looked at.</p>
<h3 id="heading-what-does-it-actually-do">What Does it Actually Do?</h3>
<p>It spins up a fresh Ubuntu machine and runs five steps in sequence:</p>
<ol>
<li><p><strong>Checkout</strong>: pulls down the branch's code</p>
</li>
<li><p><strong>Setup Java 12</strong>: installs the JDK (likely a dependency for some tooling or build process)</p>
</li>
<li><p><strong>Setup Flutter (stable channel)</strong>: this is a Flutter project, so it grabs the stable Flutter SDK</p>
</li>
<li><p><strong>Install dependencies</strong>: runs <code>flutter pub get</code> to pull all Dart/Flutter packages</p>
</li>
<li><p><strong>Run quality checks</strong>: executes the helper shell script (<code>./scripts/quality_checks.sh</code>) that we created which runs linting, tests, formatting checks, or all of the above</p>
</li>
</ol>
<h3 id="heading-the-notification-layer">The Notification Layer</h3>
<p>After the checks run, the workflow reports the verdict and it's context-aware:</p>
<ul>
<li><p><strong>If everything passes</strong>, it logs a success message with the PR URL, branch info, and the person who opened it</p>
</li>
<li><p><strong>If something fails</strong>, it logs a failure message and nudges the author to fix issues before requesting a review</p>
</li>
</ul>
<p>Both outcomes tag <code>@foluwaseyi-dev</code> and <code>@olabodegbolu</code> – the two team members responsible for staying in the loop.</p>
<p>This workflow enforces a "fix it before you merge it" culture. No one can merge broken code into <code>develop</code> without the team knowing about it.</p>
<h2 id="heading-workflow-2-androidyml">Workflow #2: Android.yml</h2>
<p>It's a better practice to split your workflows based on platform. This helps you properly manage the instructions regarding each platform. This is the reason behind keeping the Android workflow separate.</p>
<p>Unlike <code>PR _Checks</code>, this workflow presumes that all checks for quality and standards have been done and the code that runs this workflow already meets the required standards.</p>
<p>Based on our predefined use case, let's create a workflow to handle test deployments when merged to develop or staging, and production level activities when merged to production.</p>
<pre><code class="language-yaml">name: Android Build &amp; Release

on:
  push:
    branches:
      - develop
      - staging
      - production

jobs:
  android:
    runs-on: ubuntu-latest
    env:
      FLUTTER_VERSION: 'stable'

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Java
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '11'

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_VERSION }}

      - name: Install dependencies
        run: flutter pub get

      - name: Determine environment
        id: env
        run: |
          echo "branch=\({GITHUB_REF##*/}" &gt;&gt; \)GITHUB_OUTPUT
          if [ "${GITHUB_REF##*/}" = "develop" ]; then
            echo "ENV=dev" &gt;&gt; $GITHUB_OUTPUT
          elif [ "${GITHUB_REF##*/}" = "staging" ]; then
            echo "ENV=staging" &gt;&gt; $GITHUB_OUTPUT
          else
            echo "ENV=production" &gt;&gt; $GITHUB_OUTPUT
          fi

      # Dev uses hardcoded values no secrets needed
      - name: Generate config (dev)
        if: steps.env.outputs.ENV == 'dev'
        run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"

      # Staging and production inject real secrets
      - name: Generate config (staging/production)
        if: steps.env.outputs.ENV != 'dev'
        run: |
          if [ "${{ steps.env.outputs.ENV }}" = "staging" ]; then
            ./scripts/generate_config.sh staging \
              "${{ secrets.STAGING_BASE_URL }}" \
              "${{ secrets.STAGING_API_KEY }}"
          else
            ./scripts/generate_config.sh production \
              "${{ secrets.PROD_BASE_URL }}" \
              "${{ secrets.PROD_API_KEY }}"
          fi

      # Keystore is only needed for signed builds (staging &amp; production)
      - name: Restore Keystore
        if: steps.env.outputs.ENV != 'dev'
        run: |
          echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode &gt; android/app/upload-keystore.jks

      # Production builds are obfuscated + split debug info for Play Store
      - name: Build artifact
        run: |
          if [ "${{ steps.env.outputs.ENV }}" = "production" ]; then
            flutter build appbundle --release \
              --obfuscate \
              --split-debug-info=build/symbols
          else
            flutter build appbundle --release
          fi

      # Dev and staging go to Firebase App Distribution for internal testing
      - name: Upload to Firebase App Distribution
        if: steps.env.outputs.ENV == 'dev' || steps.env.outputs.ENV == 'staging'
        env:
          FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
          FIREBASE_ANDROID_APP_ID: ${{ secrets.FIREBASE_ANDROID_APP_ID }}
          FIREBASE_GROUPS: ${{ secrets.FIREBASE_GROUPS }}
        run: |
          firebase appdistribution:distribute \
            build/app/outputs/bundle/release/app-release.aab \
            --app "$FIREBASE_ANDROID_APP_ID" \
            --groups "$FIREBASE_GROUPS" \
            --token "$FIREBASE_TOKEN"

      # Only production goes to the Play Store
      - name: Upload to Play Store
        if: steps.env.outputs.ENV == 'production'
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
          packageName: com.your.package
          releaseFiles: build/app/outputs/bundle/release/app-release.aab
          track: production

      - name: Notify Team (Success)
        if: success()
        run: |
          echo "Android Build &amp; Release PASSED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"

      - name: Notify Team (Failure)
        if: failure()
        run: |
          echo "Android Build &amp; Release FAILED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"
          echo "Check the logs and fix the issue before retrying"
</code></pre>
<p>This workflow ensures that whenever code lands on the <strong>develop, staging or production</strong> branch, this action is triggered on a fresh Ubuntu machine.</p>
<p>This is triggered by a simple push to any of the tracked branches, no manual intervention needed.</p>
<p>Let's walk through it piece by piece.</p>
<h3 id="heading-1-the-setup-phase">1. The Setup Phase</h3>
<p>Before any Flutter-specific work happens, the workflow lays the foundation:</p>
<ol>
<li><p><strong>Checkout</strong>: grabs the latest code from the branch that triggered the run (using the more modern <code>actions/checkout@v3</code>).</p>
</li>
<li><p><strong>Java 11 via Temurin</strong>: this is an upgrade from the first workflow we created. Instead of a generic <code>setup-java@v1</code>, this uses the <code>temurin</code> distribution which is the Eclipse's open-source JDK build. It's the current industry standard for Android toolchains.</p>
</li>
<li><p><strong>Flutter (stable)</strong>: this pulls the stable Flutter SDK, version pinned via an environment variable (<code>FLUTTER_VERSION: 'stable'</code>) defined at the job level.</p>
</li>
<li><p><strong>Install dependencies</strong>: this ensures we run <code>flutter pub get</code> to pull all packages</p>
</li>
</ol>
<h3 id="heading-2-environment-detection">2. Environment Detection</h3>
<p>This is where it gets interesting. This workflow also checks and determines the environment which will help us define the next set of instructions to run.</p>
<p>This command reads the branch name from <strong>GITHUB REF</strong> and maps it to its environment label which we already created in one of our helper scripts.</p>
<ul>
<li><p>develop → ENV=dev</p>
</li>
<li><p>staging → ENV=staging</p>
</li>
<li><p>production → ENV=production</p>
</li>
</ul>
<p>It strips the branch name from the full ref path using <code>\({GITHUB_REF##*/}</code>, then writes both the branch name and the resolved <code>ENV</code> value to <code>\)GITHUB_OUTPUT</code>, making them available as named outputs (<code>steps.env.outputs.ENV</code>) for every subsequent step.</p>
<p>This means the rest of the pipeline can branch its behaviour based on which environment it's building for, different API keys, different signing configs, different targets – whatever the app needs.</p>
<h3 id="heading-3-config-injection">3. Config Injection</h3>
<p>With the environment resolved, the next step is injecting the right configuration into the app. This is where the <code>generate_config.sh</code> script we built earlier gets called directly from the workflow.</p>
<p>For the <code>dev</code> environment, hardcoded placeholder values are used. No real secrets are needed, since this build is only meant for internal developer testing:</p>
<pre><code class="language-yaml">- name: Generate config (dev)
  if: steps.env.outputs.ENV == 'dev'
  run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"
</code></pre>
<p>For staging and production, however, real secrets are pulled from GitHub Actions secrets and passed directly into the script:</p>
<pre><code class="language-yaml">- name: Generate config (staging/production)
  if: steps.env.outputs.ENV != 'dev'
  run: |
    if [ "${{ steps.env.outputs.ENV }}" = "staging" ]; then
      ./scripts/generate_config.sh staging \
        "${{ secrets.STAGING_BASE_URL }}" \
        "${{ secrets.STAGING_API_KEY }}"
    else
      ./scripts/generate_config.sh production \
        "${{ secrets.PROD_BASE_URL }}" \
        "${{ secrets.PROD_API_KEY }}"
    fi
</code></pre>
<p>Notice that these two steps use an <code>if</code> condition to make them mutually exclusive. Only one will ever run per job. This keeps the pipeline clean: no complicated branching logic inside the script itself, just a clear decision at the workflow level.</p>
<h3 id="heading-4-keystore-restoration">4. Keystore Restoration</h3>
<p>Android requires signed builds for distribution. The signing keystore file cannot be committed to the repository for obvious security reasons, so it's stored as a Base64-encoded GitHub secret and decoded at build time.</p>
<pre><code class="language-yaml">- name: Restore Keystore
  if: steps.env.outputs.ENV != 'dev'
  run: |
    echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode &gt; android/app/upload-keystore.jks
</code></pre>
<p>This step is skipped entirely for the <code>dev</code> environment because dev builds are unsigned debug artifacts meant purely for internal testing on Firebase App Distribution. Only staging and production builds need to be properly signed.</p>
<p>To encode your keystore file as a Base64 string for storing in GitHub secrets, you have to run this locally:</p>
<pre><code class="language-yaml">base64 -i upload-keystore.jks | pbcopy
</code></pre>
<p>This copies the encoded string to your clipboard, which you can then paste directly into your GitHub repository secrets.</p>
<h3 id="heading-5-building-the-artifact">5. Building the Artifact</h3>
<p>With the environment configured and the keystore in place, the workflow builds the app bundle:</p>
<pre><code class="language-yaml">- name: Build artifact
  run: |
    if [ "${{ steps.env.outputs.ENV }}" = "production" ]; then
      flutter build appbundle --release \
        --obfuscate \
        --split-debug-info=build/symbols
    else
      flutter build appbundle --release
    fi
</code></pre>
<p>There's a deliberate difference between how production and non-production builds are compiled.</p>
<p>For production:</p>
<ul>
<li><p><code>--obfuscate</code> renames method and class names in the compiled output, making it significantly harder to reverse engineer the app</p>
</li>
<li><p><code>--split-debug-info=build/symbols</code> extracts the debug symbols into a separate directory at <code>build/symbols</code></p>
</li>
</ul>
<p>These symbols are what <code>upload_symbols.sh</code> later ships to Sentry, so obfuscated crash reports remain readable in your monitoring dashboard.</p>
<p>For dev and staging, neither flag is used. This keeps build times faster and makes local debugging easier since stack traces remain human-readable.</p>
<h3 id="heading-6-distributing-to-firebase-app-distribution">6. Distributing to Firebase App Distribution</h3>
<p>Once the app bundle is built, dev and staging builds are uploaded to Firebase App Distribution so testers can install them immediately:</p>
<pre><code class="language-yaml">- name: Upload to Firebase App Distribution
  if: steps.env.outputs.ENV == 'dev' || steps.env.outputs.ENV == 'staging'
  env:
    FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
    FIREBASE_ANDROID_APP_ID: ${{ secrets.FIREBASE_ANDROID_APP_ID }}
    FIREBASE_GROUPS: ${{ secrets.FIREBASE_GROUPS }}
  run: |
    firebase appdistribution:distribute \
      build/app/outputs/bundle/release/app-release.aab \
      --app "$FIREBASE_ANDROID_APP_ID" \
      --groups "$FIREBASE_GROUPS" \
      --token "$FIREBASE_TOKEN"
</code></pre>
<p>Three secrets power this step:</p>
<ul>
<li><p><code>FIREBASE_TOKEN</code>: the authentication token generated from <code>firebase login:ci</code></p>
</li>
<li><p><code>FIREBASE_ANDROID_APP_ID</code>: the app identifier from the Firebase console</p>
</li>
<li><p><code>FIREBASE_GROUPS</code>: the tester group(s) that should receive the build notification</p>
</li>
</ul>
<p>Once this step completes, every tester in the specified groups receives an email with a direct download link. No one needs to manually share an APK file over Slack or email.</p>
<h3 id="heading-7-deploying-to-the-play-store">7. Deploying to the Play Store</h3>
<p>Production builds skip Firebase entirely and goes straight to the Google Play Store:</p>
<pre><code class="language-yaml">- name: Upload to Play Store
  if: steps.env.outputs.ENV == 'production'
  uses: r0adkll/upload-google-play@v1
  with:
    serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
    packageName: com.your.package
    releaseFiles: build/app/outputs/bundle/release/app-release.aab
    track: production
</code></pre>
<p>This uses the <code>r0adkll/upload-google-play</code> GitHub Action, which handles the Google Play API interaction under the hood. The only requirements are:</p>
<ul>
<li><p>A Google Play service account with the correct permissions, stored as a JSON secret</p>
</li>
<li><p>The correct package name matching what is registered in your Play Console</p>
</li>
<li><p>The <code>track</code> set to <code>production</code> (you can also use <code>internal</code>, <code>alpha</code>, or <code>beta</code> depending on your release strategy)</p>
</li>
</ul>
<p>Replace <code>com.your.package</code> with your actual application ID (the same one defined in your <code>build.gradle</code> file).</p>
<h3 id="heading-8-the-notification-layer">8. The Notification Layer</h3>
<p>Just like the PR checks workflow, this workflow reports its outcome clearly:</p>
<pre><code class="language-yaml">- name: Notify Team (Success)
  if: success()
  run: |
    echo "Android Build &amp; Release PASSED"
    echo "Environment: ${{ steps.env.outputs.ENV }}"
    echo "Branch: ${{ steps.env.outputs.branch }}"
    echo "By: @${{ github.actor }}"
    echo "Commit: ${{ github.sha }}"

- name: Notify Team (Failure)
  if: failure()
  run: |
    echo "Android Build &amp; Release FAILED"
    echo "Environment: ${{ steps.env.outputs.ENV }}"
    echo "Branch: ${{ steps.env.outputs.branch }}"
    echo "By: @${{ github.actor }}"
    echo "Commit: ${{ github.sha }}"
    echo "Check the logs and fix the issue before retrying 🔧"
</code></pre>
<p>The success notification includes the environment, branch, actor, and shares everything needed to trace exactly what was deployed and who triggered it.</p>
<p>The failure notification includes the same context, with a clear call to action.</p>
<h2 id="heading-workflow-3-iosyml">Workflow #3: iOS.yml</h2>
<p>iOS CI/CD is more complex than Android by nature. This is because Apple's signing requirements involve certificates, provisioning profiles, and entitlements that all need to be in the right place before Xcode will produce a valid archive.</p>
<p>Fastlane helps us handles all of that complexity, and the workflow simply calls into it.</p>
<p>Here is the full <code>ios.yml</code>:</p>
<pre><code class="language-yaml">name: iOS Build &amp; Release

on:
  push:
    branches:
      - develop
      - staging
      - production

jobs:
  ios:
    runs-on: macos-latest
    env:
      FLUTTER_VERSION: 'stable'

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_VERSION }}

      - name: Install dependencies
        run: flutter pub get

      - name: Determine environment
        id: env
        run: |
          echo "branch=\({GITHUB_REF##*/}" &gt;&gt; \)GITHUB_OUTPUT
          if [ "${GITHUB_REF##*/}" = "develop" ]; then
            echo "ENV=dev" &gt;&gt; $GITHUB_OUTPUT
          elif [ "${GITHUB_REF##*/}" = "staging" ]; then
            echo "ENV=staging" &gt;&gt; $GITHUB_OUTPUT
          else
            echo "ENV=production" &gt;&gt; $GITHUB_OUTPUT
          fi

      - name: Generate config (dev)
        if: steps.env.outputs.ENV == 'dev'
        run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"

      - name: Generate config (staging/production)
        if: steps.env.outputs.ENV != 'dev'
        run: |
          if [ "${{ steps.env.outputs.ENV }}" = "staging" ]; then
            ./scripts/generate_config.sh staging \
              "${{ secrets.STAGING_BASE_URL }}" \
              "${{ secrets.STAGING_API_KEY }}"
          else
            ./scripts/generate_config.sh production \
              "${{ secrets.PROD_BASE_URL }}" \
              "${{ secrets.PROD_API_KEY }}"
          fi

      - name: Install Fastlane
        run: |
          cd ios
          gem install bundler
          bundle install

      - name: Import signing certificate
        if: steps.env.outputs.ENV != 'dev'
        run: |
          echo "${{ secrets.IOS_CERTIFICATE_BASE64 }}" | base64 --decode &gt; ios/cert.p12
          security create-keychain -p "" build.keychain
          security import ios/cert.p12 -k build.keychain -P "${{ secrets.IOS_CERTIFICATE_PASSWORD }}" -T /usr/bin/codesign
          security list-keychains -s build.keychain
          security default-keychain -s build.keychain
          security unlock-keychain -p "" build.keychain
          security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain

      - name: Install provisioning profile
        if: steps.env.outputs.ENV != 'dev'
        run: |
          echo "${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}" | base64 --decode &gt; profile.mobileprovision
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/

      - name: Build iOS (dev)
        if: steps.env.outputs.ENV == 'dev'
        run: flutter build ios --release --no-codesign

      - name: Build &amp; distribute to TestFlight (staging)
        if: steps.env.outputs.ENV == 'staging'
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
        run: |
          cd ios
          bundle exec fastlane beta

      - name: Build &amp; release to App Store (production)
        if: steps.env.outputs.ENV == 'production'
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
        run: |
          cd ios
          bundle exec fastlane release

      - name: Upload Sentry symbols (production only)
        if: steps.env.outputs.ENV == 'production'
        env:
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
          SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
          SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
        run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)

      - name: Notify Team (Success)
        if: success()
        run: |
          echo "iOS Build &amp; Release PASSED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"

      - name: Notify Team (Failure)
        if: failure()
        run: |
          echo "iOS Build &amp; Release FAILED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"
          echo "Check the logs and fix the issue before retrying 🔧"
</code></pre>
<p>Let's walk through what is different about this workflow compared to that of android.</p>
<h3 id="heading-1-macos-runner">1. MacOS Runner</h3>
<pre><code class="language-yaml">runs-on: macos-latest
</code></pre>
<p>This is the major difference.</p>
<p>iOS builds require Xcode, which only runs on macOS. GitHub Actions provides hosted macOS runners, but they are significantly more expensive in terms of compute minutes than Ubuntu runners. Just keep that in mind when thinking about build frequency.</p>
<p>No Java setup is needed here. Flutter on iOS compiles through Xcode directly, so the toolchain requirements are different.</p>
<h3 id="heading-2-installing-fastlane">2. Installing Fastlane</h3>
<pre><code class="language-yaml">- name: Install Fastlane
  run: |
    cd ios
    gem install bundler
    bundle install
</code></pre>
<p>Fastlane is a Ruby-based automation tool that handles certificate management, building, and uploading to TestFlight and the App Store.</p>
<p>This step navigates into the <code>ios/</code> directory and installs Fastlane along with all its dependencies as defined in the project's <code>Gemfile</code>.</p>
<p>Your <code>ios/Gemfile</code> should look something like this:</p>
<pre><code class="language-ruby">source "https://rubygems.org"

gem "fastlane"
</code></pre>
<p>And your <code>ios/fastlane/Fastfile</code> should define at minimum two lanes: one for staging (TestFlight) and one for production (App Store):</p>
<pre><code class="language-ruby">default_platform(:ios)

platform :ios do
  lane :beta do
    build_app(scheme: "Runner", export_method: "app-store")
    upload_to_testflight(skip_waiting_for_build_processing: true)
  end

  lane :release do
    build_app(scheme: "Runner", export_method: "app-store")
    upload_to_app_store(force: true, skip_screenshots: true, skip_metadata: true)
  end
end
</code></pre>
<h3 id="heading-3-certificate-and-provisioning-profile-setup">3. Certificate and Provisioning Profile Setup</h3>
<p>This is the step that trips most teams up the first time. Apple's code signing requires two things to be present on the machine:</p>
<ol>
<li><p>The signing certificate (a <code>.p12</code> file)</p>
</li>
<li><p>The provisioning profile</p>
</li>
</ol>
<p>Both are stored as Base64-encoded GitHub secrets and restored at build time.</p>
<pre><code class="language-yaml">- name: Import signing certificate
  if: steps.env.outputs.ENV != 'dev'
  run: |
    echo "${{ secrets.IOS_CERTIFICATE_BASE64 }}" | base64 --decode &gt; ios/cert.p12
    security create-keychain -p "" build.keychain
    security import ios/cert.p12 -k build.keychain -P "${{ secrets.IOS_CERTIFICATE_PASSWORD }}" -T /usr/bin/codesign
    security list-keychains -s build.keychain
    security default-keychain -s build.keychain
    security unlock-keychain -p "" build.keychain
    security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain
</code></pre>
<p>Breaking this down step by step:</p>
<ul>
<li><p>Decodes the Base64 certificate and write it to <code>cert.p12</code></p>
</li>
<li><p>Creates a temporary keychain called <code>build.keychain</code> with an empty password</p>
</li>
<li><p>Imports the certificate into that keychain, granting codesign access</p>
</li>
<li><p>Sets it as the default keychain so Xcode finds it automatically</p>
</li>
<li><p>Unlocks the keychain so it can be used non-interactively</p>
</li>
<li><p>Sets partition list to allow access without repeated prompts</p>
</li>
</ul>
<p>The provisioning profile step is simpler:</p>
<pre><code class="language-yaml">- name: Install provisioning profile
  if: steps.env.outputs.ENV != 'dev'
  run: |
    echo "${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}" | base64 --decode &gt; profile.mobileprovision
    mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
    cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/
</code></pre>
<p>It decodes the profile and copies it into the exact directory where Xcode expects to find provisioning profiles on any macOS system.</p>
<p>To encode your certificate and profile locally, you can run these:</p>
<pre><code class="language-bash">base64 -i Certificates.p12 | pbcopy   # for the certificate
base64 -i YourApp.mobileprovision | pbcopy   # for the provisioning profile
</code></pre>
<h3 id="heading-4-building-for-each-environment">4. Building for Each Environment</h3>
<p>Dev builds skip signing entirely. They're built without code signing just to verify the project compiles correctly on a clean machine:</p>
<pre><code class="language-yaml">- name: Build iOS (dev)
  if: steps.env.outputs.ENV == 'dev'
  run: flutter build ios --release --no-codesign
</code></pre>
<p>Staging builds go through Fastlane's <code>beta</code> lane, which builds and uploads to TestFlight. Production builds go through Fastlane's <code>release</code> lane, which submits directly to App Store Connect.</p>
<p>Both staging and production steps consume the same three App Store Connect API key secrets: the key ID, the issuer ID, and the key content itself.</p>
<p>Fastlane uses these to authenticate with Apple's API without requiring a manual Apple ID login.</p>
<h3 id="heading-5-sentry-symbol-upload">5. Sentry Symbol Upload</h3>
<p>On production iOS builds, the <code>upload_symbols.sh</code> script runs after the build completes, passing the current short commit SHA as the release identifier:</p>
<pre><code class="language-yaml">- name: Upload Sentry symbols (production only)
  if: steps.env.outputs.ENV == 'production'
  env:
    SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
    SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
    SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
  run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
</code></pre>
<p>This is the same script explained earlier in the helper scripts section. It creates a Sentry release, uploads the debug information files, and finalizes the release. Any production crash from this point forward will map back to real, readable source code in your Sentry dashboard.</p>
<h2 id="heading-secrets-and-configuration-reference">Secrets and Configuration Reference</h2>
<p>For this entire pipeline to work, you need to configure the following secrets in your GitHub repository. Go to <strong>Settings → Secrets and variables → Actions → New repository secret</strong> to add each one.</p>
<p><strong>Shared (used across environments):</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>FIREBASE_TOKEN</code></td>
<td>Generated via <code>firebase login:ci</code> on your local machine</td>
</tr>
<tr>
<td><code>FIREBASE_ANDROID_APP_ID</code></td>
<td>Android app ID from your Firebase console</td>
</tr>
<tr>
<td><code>FIREBASE_GROUPS</code></td>
<td>Comma-separated tester group names in Firebase</td>
</tr>
<tr>
<td><code>SENTRY_AUTH_TOKEN</code></td>
<td>Auth token from your Sentry account settings</td>
</tr>
<tr>
<td><code>SENTRY_ORG</code></td>
<td>Your Sentry organization slug</td>
</tr>
<tr>
<td><code>SENTRY_PROJECT</code></td>
<td>Your Sentry project slug</td>
</tr>
</tbody></table>
<p><strong>Staging:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>STAGING_BASE_URL</code></td>
<td>Your staging API base URL</td>
</tr>
<tr>
<td><code>STAGING_API_KEY</code></td>
<td>Your staging API or encryption key</td>
</tr>
</tbody></table>
<p><strong>Production:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>PROD_BASE_URL</code></td>
<td>Your production API base URL</td>
</tr>
<tr>
<td><code>PROD_API_KEY</code></td>
<td>Your production API or encryption key</td>
</tr>
</tbody></table>
<p><strong>Android:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>ANDROID_KEYSTORE_BASE64</code></td>
<td>Base64-encoded <code>.jks</code> keystore file</td>
</tr>
<tr>
<td><code>GOOGLE_PLAY_SERVICE_ACCOUNT_JSON</code></td>
<td>Full JSON content of your Play Console service account</td>
</tr>
</tbody></table>
<p><strong>iOS:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>IOS_CERTIFICATE_BASE64</code></td>
<td>Base64-encoded <code>.p12</code> signing certificate</td>
</tr>
<tr>
<td><code>IOS_CERTIFICATE_PASSWORD</code></td>
<td>Password protecting the <code>.p12</code> file</td>
</tr>
<tr>
<td><code>IOS_PROVISIONING_PROFILE_BASE64</code></td>
<td>Base64-encoded <code>.mobileprovision</code> file</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_API_KEY_ID</code></td>
<td>Key ID from App Store Connect → Users &amp; Access → Keys</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_API_ISSUER_ID</code></td>
<td>Issuer ID from the same App Store Connect page</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_API_KEY_CONTENT</code></td>
<td>The full content of the downloaded <code>.p8</code> key file</td>
</tr>
</tbody></table>
<p>None of these values should ever appear in your codebase. If any secret is accidentally committed, rotate it immediately.</p>
<h2 id="heading-end-to-end-flow">End-to-End Flow</h2>
<p>With all three workflows in place, here is exactly what happens from the moment a developer opens a pull request to the moment a user receives an update:</p>
<h3 id="heading-1-developer-opens-a-pr-into-develop">1. Developer Opens a PR into <code>develop</code></h3>
<p>The <code>pr_checks.yml</code> workflow fires. It runs formatting checks, static analysis, and the full test suite. If anything fails, the PR cannot be merged and the team is notified immediately. The developer fixes the issues and pushes again, which triggers a fresh run.</p>
<h3 id="heading-2-pr-is-approved-and-merged-into-develop">2. PR is Approved and Merged into <code>develop</code></h3>
<p>The <code>android.yml</code> and <code>ios.yml</code> workflows both fire on the push event. They detect the environment as <code>dev</code>, inject placeholder config, build unsigned artifacts, and upload them to Firebase App Distribution. Testers receive an email and can install the build on their devices within minutes – no one shared a file manually.</p>
<h3 id="heading-3-develop-is-merged-into-staging">3. <code>develop</code> is Merged into <code>staging</code></h3>
<p>Both platform workflows fire again. This time the environment resolves to <code>staging</code>. Real secrets are injected, builds are properly signed, and the artifacts go to Firebase App Distribution (Android) and TestFlight (iOS). QA begins testing the staging build against the staging API.</p>
<h3 id="heading-4-staging-is-merged-into-production">4. <code>staging</code> is merged into <code>production</code></h3>
<p>Both workflows fire one final time. Production secrets are injected, builds are obfuscated and signed, debug symbols are uploaded to Sentry, and the final artifacts are submitted to the Google Play Store and App Store Connect. The release goes live on Apple and Google's review timelines with no further human intervention required.</p>
<p>From that first PR to a production submission, not a single command was run manually.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Building this pipeline is an upfront investment that pays off from the very first release cycle. What used to be a sequence of error-prone manual steps building locally, signing, uploading, switching configs, and hoping nothing was mixed up is now a fully automated, auditable, and repeatable process that runs the moment code moves between branches.</p>
<p>The architecture we built here does more than just automate builds. The PR quality gate enforces team standards consistently, so code review becomes a conversation about intent rather than a hunt for formatting issues. The environment-aware config injection eliminates an entire class of production incidents where staging keys made it into a live release. The Sentry symbol upload means your team can debug production crashes with full source visibility even from an obfuscated binary.</p>
<p>Every piece of this pipeline also runs locally. The helper scripts in the <code>scripts/</code> folder are plain Bash so you can call them from your terminal the same way CI calls them. This eliminates the frustrating cycle of pushing a commit just to test a pipeline change.</p>
<p>As your team grows, this foundation scales with you. You can extend the <code>pr_checks.yml</code> to enforce code coverage thresholds, add a performance benchmarking job, or introduce a dedicated security scanning step. You can extend the platform workflows to support multiple flavors, multiple Firebase projects, or staged rollouts on the Play Store. The architecture stays the same – you're just adding new steps to an already working system.</p>
<p>This ensures that standards are met, code quality remains high, you have a proper team structure, clear process and automated post development activities are in place – and at the end of the day, you'll have an optimized engineering approach that will help your team in so many ways.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ CI/CD in Production with Jenkins ]]>
                </title>
                <description>
                    <![CDATA[ Automation is no longer just a "nice-to-have" skill, it powers modern software development. To help you master automation and CI/CD, we’ve just released a massive 17-hour Jenkins course on the freeCod ]]>
                </description>
                <link>https://www.freecodecamp.org/news/ci-cd-in-production-with-jenkins/</link>
                <guid isPermaLink="false">69b199496c896b0519a8964c</guid>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Jenkins ]]>
                    </category>
                
                    <category>
                        <![CDATA[ youtube ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Beau Carnes ]]>
                </dc:creator>
                <pubDate>Wed, 11 Mar 2026 16:33:13 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5f68e7df6dfc523d0a894e7c/8a580ac3-b25c-40ba-a1cc-1787c4aaf899.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Automation is no longer just a "nice-to-have" skill, it powers modern software development. To help you master automation and CI/CD, we’ve just released a massive 17-hour Jenkins course on the <a href="http://freeCodeCamp.org">freeCodeCamp.org</a> YouTube channel. This course is a complete journey from the core foundations of the Modern SDLC to the complex world of production-grade DevSecOps.</p>
<p>Here are the key sections in this course:</p>
<ul>
<li><p>Modern SDLC Explained</p>
</li>
<li><p>CI/CD Concepts &amp; Branching Strategies</p>
</li>
<li><p>Jenkins Basics &amp; Installation</p>
</li>
<li><p>Jenkins Freestyle Jobs Deep Dive</p>
</li>
<li><p>CI/CD Project | Dockerized Flask App</p>
</li>
<li><p>Jenkins Pipelines (Build, Push, Deploy)</p>
</li>
<li><p>Transition to Multibranch Pipelines</p>
</li>
<li><p>Maven for DevOps</p>
</li>
<li><p>DevSecOps Explained</p>
</li>
<li><p>DevSecOps Mega Project</p>
</li>
<li><p>Jenkins Shared Libraries</p>
</li>
</ul>
<p>Watch the full course on <a href="https://youtu.be/uHNOqKdqQas">the freeCodeCamp.org YouTube channel</a> (17-hour watch).</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/uHNOqKdqQas" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use to Docker with Node.js: A Handbook for Developers ]]>
                </title>
                <description>
                    <![CDATA[ In this handbook, you’ll learn what Docker is and why it’s a must-have skill for backend and full-stack developers. And, most importantly, you’ll learn how to use it in real-world projects from start to finish. We will go far beyond the usual “Hello ... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-to-docker-with-nodejs-handbook/</link>
                <guid isPermaLink="false">691cf09fea147a95b92d3551</guid>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Technical writing  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oghenekparobo Stephen ]]>
                </dc:creator>
                <pubDate>Tue, 18 Nov 2025 22:18:07 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1763502750050/74610cbc-124b-48aa-9cb6-7ed861123511.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this handbook, you’ll learn what Docker is and why it’s a must-have skill for backend and full-stack developers. And, most importantly, you’ll learn how to use it in real-world projects from start to finish.</p>
<p>We will go far beyond the usual “Hello World” examples and walk you through containerizing a complete full-stack JavaScript application (Node.js + Express backend, HTML/CSS/JS frontend, MongoDB database, and Mongo Express admin UI).</p>
<p>You’ll learn about networking multiple containers, orchestrating everything with Docker Compose, building and versioning your own images, persisting data with volumes, and securely pushing your Images to a private AWS ECR repository for sharing and production deployment.</p>
<p>By the end, you’ll be able to eliminate “it works on my machine” issues, confidently manage multi-service applications, deploy consistent environments anywhere, and integrate Docker into your daily workflow and CI/CD pipelines like a pro.</p>
<p>Since Docker is such a key skill for backend developers, we’ll start by covering its basic concepts.</p>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>This technical handbook is designed for developers who have some practical, hands-on experience in full-stack development. You should be comfortable deploying applications and have a basic understanding of CI/CD pipelines.</p>
<p>While we’ll cover Docker from the ground up, this guide is not for absolute beginner developers. I assume you have real-world development experience and want to level up your workflow with Docker.</p>
<p>Finally, a basic familiarity with AWS and general deployment concepts will also be useful, though you don’t need to be an expert. This handbook is ideal for developers looking to enhance their production-grade skills and confidently integrate Docker into their projects.</p>
<h2 id="heading-table-of-contents">Table of Contents:</h2>
<ol>
<li><p><a class="post-section-overview" href="#heading-what-is-a-container">What is a Container?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-docker-vs-virtual-machines">Docker vs Virtual Machines</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-docker-installation">Docker Installation</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-basic-docker-commands">Basic Docker Commands</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-practice-with-javascript">Practice with JavaScript</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-how-to-pull-the-mongodb-image">How to Pull the MongoDB Image</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-pull-the-mongo-express-image">How to Pull the Mongo Express Image</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-docker-network">Docker Network</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-run-the-mongo-container">How to Run the Mongo Container</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-run-the-mongo-express-container">How to Run the Mongo Express Container</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-connect-nodejs-to-mongodb">How to Connect Node.js to MongoDB</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-use-docker-compose">How to Use Docker Compose</a></p>
<ul>
<li><a class="post-section-overview" href="#heading-why-use-docker-compose">Why Use Docker Compose?</a></li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-build-our-own-docker-image">How to Build Our Own Docker Image</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-the-solution">The Solution</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-why-mongodb-works">Why Mongodb Works</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-add-your-app-to-docker-compose">Add Your App to Docker Compose</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-start-all-services">Start All Services</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-verify-everything-works">Verify Everything Works</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-changed-and-why-it-works">What Changed and Why It Works</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-manage-your-containers">How to Manage Your Containers</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-create-a-private-docker-repository">How to Create a Private Docker Repository</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-step-1-get-your-aws-access-keys">Step 1: Get Your AWS Access Keys</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-2-check-if-aws-cli-is-installed">Step 2: Check if AWS CLI is Installed</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-3-configure-aws-cli">Step 3: Configure AWS CLI</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-4-test-your-aws-configuration">Step 4: Test Your AWS Configuration</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-5-login-to-ecr-docker-registry">Step 5: Login to ECR (Docker Registry)</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-understanding-image-naming-in-docker-repositories">Understanding Image Naming in Docker Repositories</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-6-build-tag-and-push-your-image">Step 6: Build, Tag, and Push Your Image</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-assignment-create-and-push-a-new-version">Assignment: Create and Push a New Version</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-deploying-our-image">Deploying Our Image</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-why-must-we-use-the-full-image-url-for-ecr">Why Must We Use the Full Image URL for ECR</a>?</p>
</li>
<li><p><a class="post-section-overview" href="#heading-deploy-your-app-using-docker-compose">Deploy Your App Using Docker Compose</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-sharing-our-private-docker-image">Sharing Our Private Docker Image</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-docker-volumes">Docker Volumes</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-how-docker-volumes-work">How Docker Volumes Work</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-types-of-docker-volumes">Types of Docker Volumes</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-example-docker-compose-file-using-volumes">Example Docker Compose File Using Volumes</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-start-your-application">Start Your Application</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-what-is-a-container">What is a Container?</h2>
<p>A container is a way to package an application together with everything it needs, including its dependencies, libraries, and configuration files.</p>
<p>Because containers are portable, they can be shared across teams and deployed on any machine without worrying about compatibility.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762863191484/827d0731-a392-419f-b17b-9a3611a4f3b4.jpeg" alt="pictures of stack containers, to portrait or give an idea what containers are or a vivid pictureof containers aliking to containers in docker" class="image--center mx-auto" width="736" height="736" loading="lazy"></p>
<h3 id="heading-where-do-containers-live">Where Do Containers Live?</h3>
<p>Since containers are portable and can be shared across teams and systems, they need a place to live. That’s where container repositories come in – special storage locations for containers. Organizations can have private repositories for internal use, while public ones like <a target="_blank" href="https://hub.docker.com/">Docker Hub</a> let anyone browse and use shared containers.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762863680430/caddd581-08e1-45c7-a676-818ad364f56b.png" alt="an image of docker hub, showing a catalogue of images" class="image--center mx-auto" width="2874" height="1718" loading="lazy"></p>
<p>If you visit the catalog page on Docker Hub, you will see a variety of container repositories, both official and community-made, from developers and teams like Redis, Jenkins, and many others.</p>
<p>In the past, when multiple developers worked on different projects, each had to manually install services on their own systems. Since different developers often use different operating systems like Linux, macOS, and Windows, the setup process was never the same. It took a lot of time, led to plenty of errors, and made setting up new environments a real headache, especially when you had to repeat it for multiple services.</p>
<p>Docker changed the game for developers and teams. Instead of manually installing every service and dependency, you can just run a single Docker command to start a container. Each container has its own isolated environment with everything it needs, so it runs the same on any machine, no matter if it’s Windows, macOS, or Linux. This makes collaboration smoother and eliminates all the bottlenecks that come from different setups, missing dependencies, or version mismatches.</p>
<p>In short, Docker is a platform that packages your app and its dependencies into a single, portable container, so it runs the same way everywhere.</p>
<h2 id="heading-docker-vs-virtual-machines">Docker vs Virtual Machines</h2>
<p>Docker and virtual machines (VMs) are both ways to run apps in a “virtual” environment, but they work differently. To understand the differences, it helps to know a bit about how computers run software.</p>
<p>A quick look at the layers:</p>
<ul>
<li><p><strong>Kernel:</strong> This is the part of the operating system that talks to your computer’s hardware, like the CPU, memory, and disk. Think of it as the middleman between your apps and your computer.</p>
</li>
<li><p><strong>Application layer:</strong> This is where programs and apps run. It sits on top of the kernel and uses it to access hardware resources.</p>
</li>
</ul>
<p>So, now let’s get into a bit more detail about Virtual Machines. A VM virtualizes the <strong>entire operating system</strong>, which means it comes with its own kernel and its own application layer. When you download a VM, you are basically getting a full OS inside your computer, often several gigabytes in size.</p>
<p>Because it has to boot its own OS, VMs start slowly. But VMs are very compatible, and can run on almost any host because they include everything they need.</p>
<p>Docker, on the other hand, only virtualizes the <strong>application layer</strong>, not the full OS. Containers share the host system’s kernel but include everything the app needs, dependencies, libraries, and configuration.</p>
<p>Docker images are small, often just a few megabytes. Containers start almost instantly because they don’t boot a full OS. A Docker container can run anywhere Docker is installed, no matter what operating system your computer uses.</p>
<p>In simple terms, to summarize:</p>
<ul>
<li><p>A VM is like running a whole computer inside your computer – big, heavy, and slow.</p>
</li>
<li><p>A Docker container is like a self-contained app package – small, fast, and portable.</p>
</li>
</ul>
<p>Here’s a quick comparison:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Feature</td><td>Virtual Machine</td><td>Docker Container</td></tr>
</thead>
<tbody>
<tr>
<td>Size</td><td>GBs (large)</td><td>MBs (small)</td></tr>
<tr>
<td>Startup Speed</td><td>Slow</td><td>Fast</td></tr>
<tr>
<td>OS Layer</td><td>Full OS + kernel</td><td>Shares host kernel</td></tr>
<tr>
<td>Portability</td><td>Runs on compatible host</td><td>Runs anywhere Docker is installed</td></tr>
</tbody>
</table>
</div><h2 id="heading-docker-installation">Docker Installation</h2>
<p>Alright, now that you know what Docker is, let’s get it running on your own machine.</p>
<p>Docker works on Windows, macOS, and Linux, but each system has slightly different steps. The official Docker <a target="_blank" href="https://docs.docker.com/get-started/introduction/">documentation</a> has clear instructions for all operating systems under Docker Docs: Install Docker.</p>
<p>If you are more of a visual learner, this YouTube video walks you through installing Docker on Windows and Linux step by step: <a target="_blank" href="https://www.youtube.com/watch?v=BuGEGM_elXY">Watch here</a>.</p>
<p>Here is a simple roadmap:</p>
<p>First, check your system requirements. Docker won’t run on every computer, so make sure your OS version is supported (the official <a target="_blank" href="https://docs.docker.com/engine/install/">docs</a> have a checklist).</p>
<ol>
<li><p>Windows and macOS users:</p>
<ul>
<li><p><strong>Newer systems:</strong> Download and install <a target="_blank" href="https://docs.docker.com/desktop/"><strong>Docker Desktop</strong></a><strong>.</strong> It’s the easiest way to get started.</p>
</li>
<li><p><strong>Older systems:</strong> If your computer doesn’t support Docker Desktop (for example, missing Hyper-V or older OS versions), you can use <a target="_blank" href="https://docker-docs.uclv.cu/toolbox/toolbox_install_windows/"><strong>Docker Toolbox</strong></a>. Toolbox installs Docker using a lightweight virtual machine, so you can still run containers even on older machines.</p>
</li>
</ul>
</li>
<li><p>Linux users: You will usually install Docker through your package manager (<code>apt</code> for Ubuntu/Debian, <code>yum</code> for CentOS/Fedora, etc.). The official <a target="_blank" href="https://docs.docker.com/desktop/setup/install/linux/">docs</a> show the commands for your distro.</p>
</li>
</ol>
<p>Then verify your installation: Open a terminal or command prompt and type:</p>
<pre><code class="lang-bash">docker --version
</code></pre>
<p>If you see the Docker version displayed, congratulations! Docker is ready to go.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762871221981/6b01cf18-a8b5-4aa9-b213-38cffd4ae5f4.png" alt="docker version displayed on cli" class="image--center mx-auto" width="632" height="34" loading="lazy"></p>
<p>Once Docker is installed, you’ll be ready to start running containers, pulling images, and experimenting with your apps in a safe, isolated environment.</p>
<p><strong>Tip for beginners:</strong></p>
<p>If you’re on an older machine and using Docker Toolbox, commands are mostly the same, but you will run them inside the <strong>Docker Quickstart Terminal</strong>, which sets up the virtual machine for you.</p>
<h2 id="heading-basic-docker-commands">Basic Docker Commands</h2>
<p>So far, we have been throwing around terms like images and containers, sometimes even interchangeably. But there is an important difference:</p>
<ul>
<li><p><strong>Docker image:</strong> Think of an image as a <strong>blueprint</strong> or a package. It contains everything your app needs: the code, libraries, dependencies, and configuration, but it’s not running yet.</p>
</li>
<li><p><strong>Docker container:</strong> A container is a <strong>running instance of an image</strong>. When you start a container, Docker takes the image and runs it in its own isolated environment.</p>
</li>
</ul>
<p>A helpful way to remember it is this: the image is the recipe, while the container is the cake**.** You can have one recipe (image) and make multiple cakes (containers) from it.</p>
<p><strong>Important note:</strong> Docker Hub stores images, not containers. So when you pull something from Docker Hub, you’re downloading an image. For example:</p>
<pre><code class="lang-bash">docker pull redis
</code></pre>
<p>Here’s what you’ll see:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762872367018/39039261-9617-4e5f-8156-9529697d0667.png" alt="docker run redis shown on cli" class="image--center mx-auto" width="1130" height="410" loading="lazy"></p>
<p>This command downloads the Redis image to your machine. Once the download is complete, you can see all the images you have locally with:</p>
<pre><code class="lang-bash">docker images
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762872440545/bfdb401f-7dd6-4f72-920b-545fbf5193e1.png" alt="running docker images on cli" class="image--center mx-auto" width="1654" height="880" loading="lazy"></p>
<p>From there, you can start a container from an image whenever you need it:</p>
<pre><code class="lang-bash">docker run -d --name my-redis redis
</code></pre>
<p>This command starts a container, <code>my-redis</code>, from the <code>redis</code> image you just pulled.</p>
<ul>
<li><p><code>docker run</code> tells Docker to start a new container from an image.</p>
</li>
<li><p><code>-d</code> stands for “detached mode.” It means the container runs in the background so you can keep using your terminal.</p>
</li>
<li><p><code>--name my-redis</code> gives your container a friendly name (<code>my-redis</code>) instead of letting Docker assign a random one. It makes it easier to manage later.</p>
</li>
<li><p><code>redis</code> is the image you are using to start the container.</p>
</li>
</ul>
<p>To see all containers that are currently running, you can use:</p>
<pre><code class="lang-bash">docker ps
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762873018123/2e184c25-e4f1-445c-b182-81987929c014.png" alt="ran docker ps in the terminal to list all running containers" class="image--center mx-auto" width="1518" height="396" loading="lazy"></p>
<p>This will list containers with details like:</p>
<ul>
<li><p>Container ID</p>
</li>
<li><p>Name</p>
</li>
<li><p>Status (running or stopped)</p>
</li>
<li><p>The image it’s running from</p>
</li>
</ul>
<p>If you want to see all containers, even ones that aren’t running, you can add the <code>-a</code> flag:</p>
<pre><code class="lang-bash">docker ps -a
</code></pre>
<h3 id="heading-how-to-specify-a-version-of-an-image">How to Specify a Version of an Image:</h3>
<p>By default, Docker pulls the <strong>latest version</strong> of an image. But sometimes you might need a specific version. You can do this using a colon (<code>:</code>) followed by the version tag. For example:</p>
<pre><code class="lang-bash">docker pull redis:7.2
docker run -d --name my-redis redis:7.2
</code></pre>
<p>To know which versions are available, you can visit <a target="_blank" href="https://hub.docker.com/repositories"><strong>Docker Hub</strong></a> or check the image tags online. Also, running <code>docker images</code> on your machine will show you all downloaded images and their versions.</p>
<h3 id="heading-how-to-stop-start-and-remove-a-container">How to Stop, Start, and Remove a Container</h3>
<p>If you want to stop a running container, run this:</p>
<pre><code class="lang-bash">docker stop my-redis
</code></pre>
<p>To start it again:</p>
<pre><code class="lang-bash">docker start my-redis
</code></pre>
<p>You can also <strong>remove a container</strong> if you no longer need it:</p>
<pre><code class="lang-bash">docker rm my-redis
</code></pre>
<h3 id="heading-how-to-restart-a-container">How to Restart a Container</h3>
<p>You can restart a container using its <strong>container ID</strong> (or name) if something crashes, needs a refresh, or you just want to apply changes.</p>
<p>For example:</p>
<pre><code class="lang-bash">docker ps
CONTAINER ID   IMAGE     COMMAND                  CREATED         STATUS         PORTS      NAMES
c002bed0ae9a   redis     <span class="hljs-string">"docker-entrypoint.s…"</span>   3 minutes ago   Up 3 minutes   6379/tcp   my-redis
</code></pre>
<p>Restart it like this:</p>
<pre><code class="lang-bash">docker restart c002bed0ae9a
</code></pre>
<p>or by name:</p>
<pre><code class="lang-bash">docker restart my-redis
</code></pre>
<p>Other handy ways:</p>
<ul>
<li><p><strong>Stop then start</strong></p>
<pre><code class="lang-bash">  docker stop c002bed0ae9a
  docker start c002bed0ae9a
</code></pre>
</li>
<li><p><strong>Start with logs</strong></p>
<pre><code class="lang-bash">  docker start c002bed0ae9a &amp;&amp; docker logs -f c002bed0ae9a
</code></pre>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762873445952/ffb56b5d-f850-4b53-998d-467ed431a191.png" alt="starting a docker container with logs" class="image--center mx-auto" width="2880" height="1362" loading="lazy"></p>
<h3 id="heading-how-to-run-multiple-redis-containers-and-understanding-ports">How to Run Multiple Redis Containers and Understanding Ports</h3>
<p>Right now, you have a Redis container running:</p>
<pre><code class="lang-bash">docker ps
</code></pre>
<p>It shows something like this:</p>
<pre><code class="lang-bash">CONTAINER ID   IMAGE     COMMAND                  STATUS          PORTS      NAMES
c002bed0ae9a   redis     <span class="hljs-string">"docker-entrypoint.s…"</span>   Up 20 minutes   6379/tcp   my-redis
</code></pre>
<p>Notice the <strong>PORTS</strong> column: <code>6379/tcp</code>. This means the container is running Redis on its internal port 6379. By default, this port is inside the container and is not automatically exposed to your computer (the host). Docker maps it only if you specify it.</p>
<h4 id="heading-trying-to-run-another-redis-container-on-the-same-port">Trying to Run Another Redis Container on the Same Port</h4>
<p>If you try:</p>
<pre><code class="lang-bash">docker run -d --name my-redis2 redis:7.4.7-alpine
</code></pre>
<p>It will fail to map the host port 6379 because the first container is already using it. This is where port binding comes in.</p>
<h4 id="heading-what-is-port-binding">What is Port Binding?</h4>
<p>Port binding (also called port mapping) is the mechanism Docker uses to connect a port inside a container to a port on your host machine (your laptop/desktop/server).</p>
<p>Without port binding, any service running inside a container is completely isolated: it can listen on its internal ports (for example, Redis on 6379, a Node.js app on 3000, MongoDB on 27017), but nothing outside the container, including your browser, another app on your computer, or even another container on a different network, can reach it.</p>
<ul>
<li><p><strong>Container Port</strong>: The port inside the container where the app is running (Redis defaults to <code>6379</code>).</p>
</li>
<li><p><strong>Host Port</strong>: The port on your computer that you want to use to access that container.</p>
</li>
</ul>
<p>Docker lets you map a container port to a different host port using the <code>-p</code> flag.</p>
<h4 id="heading-running-a-second-redis-container-on-a-different-host-port">Running a Second Redis Container on a Different Host Port</h4>
<pre><code class="lang-bash">docker run -d --name my-redis2 -p 6380:6379 redis:7.4.7-alpine
</code></pre>
<p><code>-p 6380:6379</code> maps host port 6380 to container port 6379.</p>
<ul>
<li><p>Now you can connect to Redis in the second container using <code>localhost:6380</code>.</p>
</li>
<li><p>Inside the container, Redis still runs on port 6379.</p>
</li>
</ul>
<p>Check both containers:</p>
<pre><code class="lang-bash">docker ps
</code></pre>
<p>Output will look like this:</p>
<pre><code class="lang-bash">CONTAINER ID   IMAGE     STATUS          PORTS             NAMES
c002bed0ae9a   redis     Up 20 minutes   6379/tcp          my-redis
d123abcd5678   redis     Up 1 minute     0.0.0.0:6380-&gt;6379/tcp   my-redis2
</code></pre>
<p>The first container is running internally on 6379 (host port not exposed), while the second container is mapped so host port 6380 forwards traffic to container port 6379.</p>
<p>Think of each container as a room with a phone line (container port).</p>
<ul>
<li><p>You want to call that room from the outside (host).</p>
</li>
<li><p>You can’t use the same external phone line for two rooms at the same time.</p>
</li>
<li><p>With <strong>port binding</strong>, you assign a different external line for each room, even if the internal phone number is the same.</p>
</li>
</ul>
<h4 id="heading-why-port-binding-exists">Why Port Binding Exists</h4>
<ol>
<li><p><strong>Avoid port conflicts on the host:</strong> Only one process on your computer can use a given port at a time. If you already have one Redis container using host port 6379, a second container cannot also bind to the same host port. Port binding lets you run many identical containers side-by-side by mapping each one to a different host port (6379 → 6380, 6381, etc.).</p>
</li>
<li><p><strong>Access containerised services from your host:</strong> Your browser, Postman, MongoDB Compass, redis-cli, curl, etc., all run on the host. Without -p, they have no way to talk to services inside containers.</p>
</li>
<li><p><strong>Selective exposure:</strong> You don’t have to expose every port a container uses. Only map the ports you actually need externally, keeping the rest private and secure.</p>
</li>
</ol>
<p>It also gives you more flexibility in development and production. In development, you might map container 3000 to host 3000. But in production (for example, behind a reverse proxy), you might map container 3000 to host 80 or 443, or not expose it at all and let another container talk to it over Docker’s internal network.</p>
<h3 id="heading-how-to-explore-a-container">How to Explore a Container</h3>
<p>To explore a container, run:</p>
<pre><code class="lang-bash">docker <span class="hljs-built_in">exec</span> -it my-redis2 /bin/sh
</code></pre>
<ul>
<li><p><code>docker exec</code> runs a command in the container.</p>
</li>
<li><p><code>-it</code> interactive terminal (lets you type and see output).</p>
</li>
<li><p><code>/bin/sh</code> starts a shell inside the container.</p>
</li>
</ul>
<p>Once inside, your prompt changes to something like:</p>
<pre><code class="lang-bash">/data <span class="hljs-comment">#</span>
</code></pre>
<p>Now you can <strong>list files</strong>, navigate directories, or run programs, all inside the container, without affecting your host machine.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762876729378/d16e8f00-ab9c-447b-b274-76d613b30ce3.png" alt="result of running docker exec -it my-redis2 /bin/sh" class="image--center mx-auto" width="2880" height="1800" loading="lazy"></p>
<h3 id="heading-docker-run-vs-docker-start"><code>docker run</code> vs <code>docker start</code></h3>
<p>We have been using <code>docker run</code> and <code>docker start</code> throughout this article, but here’s why the difference is important:</p>
<ul>
<li><p><strong>Avoid accidental duplicates:</strong> Using <code>docker run</code> every time creates a new container. If you just want to restart something you already set up, <code>docker start</code> is faster and safer.</p>
</li>
<li><p><strong>Maintain configuration:</strong> <code>docker start</code> preserves the container’s original settings, ports, volumes, and names so you don’t risk breaking anything by changing options.</p>
</li>
<li><p><strong>Work efficiently with multiple containers:</strong> When running multiple services or different versions of the same app, knowing when to <code>run</code> vs <code>start</code> helps you manage resources, avoid port conflicts, and keep your workflow smooth.</p>
</li>
<li><p><strong>Speed up your workflow:</strong> Starting existing containers is almost instant, while creating a new one takes slightly longer.</p>
</li>
</ul>
<p><strong>Bottom line</strong> <code>docker run</code> = create something new, while <code>docker start</code> = resume what you already have.</p>
<h2 id="heading-practice-with-javascript">Practice with JavaScript</h2>
<p>Now that we have covered the core Docker concepts, let’s put them into action. In this section, we’ll containerize a simple JavaScript project that consists of:</p>
<ul>
<li><p><strong>A frontend:</strong> Built with HTML, CSS, and JavaScript</p>
</li>
<li><p><strong>A backend:</strong> A simple Node.js server (<code>server.js</code>)</p>
</li>
<li><p><strong>A database:</strong> A MongoDB instance pulled directly from Docker Hub</p>
</li>
<li><p><strong>A UI for MongoDB:</strong> Using <strong>Mongo Express</strong> to visualize and manage our database</p>
</li>
</ul>
<p>This example demonstrates how Docker can manage multiple components of an application, including code, dependencies, and services in isolated, consistent environments.</p>
<p>You can <a target="_blank" href="https://github.com/Oghenekparobo/docker_tut_js">pull the starter project from GitHub here</a>.</p>
<p>Or clone it directly using your terminal:</p>
<pre><code class="lang-bash">git <span class="hljs-built_in">clone</span> https://github.com/Oghenekparobo/docker_tut_js.git
<span class="hljs-built_in">cd</span> docker_tut_js
</code></pre>
<p>This contains the basic HTML and JavaScript files along with the Node.js backend.</p>
<p>Next, we will prepare to set up our database. Head over to <a target="_blank" href="https://hub.docker.com/">Docker Hub</a> and type <strong>“mongo”</strong> in the search box. You will see the official MongoDB image published by Docker.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762950041097/89f54e21-f607-488a-8d98-d688733270c4.png" alt="official mongo db database in dockerhub" class="image--center mx-auto" width="2880" height="1504" loading="lazy"></p>
<h3 id="heading-how-to-pull-the-mongodb-image">How to Pull the MongoDB Image</h3>
<p>Now that you have explored the official MongoDB image on Docker Hub, let’s actually pull it into your local environment.</p>
<p>Open your terminal, navigate to your project directory (for example, <code>docker_tut_js</code>), and run:</p>
<pre><code class="lang-bash">docker pull mongo
</code></pre>
<p>This command tells Docker to download the latest version of the MongoDB image from Docker Hub.</p>
<p>You will see output similar to this:</p>
<pre><code class="lang-bash">Using default tag: latest
latest: Pulling from library/mongo
b8a35db46e38: Already exists 
a637dbfff7e5: Pull complete 
0c9047ace63c: Pull complete 
02cd4cf70021: Pull complete 
dfb5d357a025: Pull complete 
007bf0024f67: Pull complete 
67fd8af3998d: Pull complete 
d702312e8109: Pull complete 
Digest: sha256:7d1a1a613b41523172dc2b1b02c706bc56cee64144ccd6205b1b38703c85bf61
Status: Downloaded newer image <span class="hljs-keyword">for</span> mongo:latest
docker.io/library/mongo:latest
</code></pre>
<p>Here’s what’s happening:</p>
<ul>
<li><p><strong>“Using default tag: latest”</strong>: Docker pulls the most recent version of MongoDB since no specific version was provided.</p>
</li>
<li><p><strong>“Pulling from library/mongo”</strong>: It’s downloading from Docker’s official image library.</p>
</li>
<li><p><strong>“Pull complete”</strong>: Each line represents a layer of the image being successfully downloaded.</p>
</li>
<li><p><strong>“Downloaded newer image for mongo:latest”</strong>: Confirms that the MongoDB image is now stored locally on your system.</p>
</li>
</ul>
<p>You can confirm that it’s available by running:</p>
<pre><code class="lang-bash">docker images
</code></pre>
<p>You should see <strong>mongo</strong> listed in the repository column.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762950246712/343d4d23-9c61-4480-956c-a5c2cd391889.png" alt="mongo db listed in the repository column after running docker images" class="image--center mx-auto" width="1164" height="740" loading="lazy"></p>
<h3 id="heading-how-to-pull-the-mongo-express-image">How to Pull the Mongo Express Image</h3>
<p>Now that the MongoDB image is ready, let’s pull the <strong>Mongo Express</strong> image.</p>
<p>Mongo Express is a lightweight web-based interface that lets you view and manage your MongoDB collections through a browser, similar to how phpMyAdmin works for MySQL.</p>
<p>Open your terminal (still in your project directory) and run:</p>
<pre><code class="lang-bash">docker pull mongo-express
</code></pre>
<p>You’ll see output similar to this:</p>
<pre><code class="lang-bash">Using default tag: latest
latest: Pulling from library/mongo-express
b8a35db46e38: Already exists
a637dbfff7e5: Pull complete
4e0e0977e9c3: Pull complete
02cd4cf70021: Pull complete
Digest: sha256:3d6dbac587ad91d0e2eab83f09a5b31a1c8f9d91a8825ddaa6c7453c25cb4812
Status: Downloaded newer image <span class="hljs-keyword">for</span> mongo-express:latest
docker.io/library/mongo-express:latest
</code></pre>
<p>Here’s what this means:</p>
<ul>
<li><p><code>docker pull mongo-express</code> downloads the official Mongo Express image from Docker Hub.</p>
</li>
<li><p>Each <strong>“Pull complete”</strong> line represents a successfully downloaded layer of the image.</p>
</li>
<li><p><code>mongo-express:latest</code> confirms that the latest version is now stored locally.</p>
</li>
</ul>
<p>To verify that both images are available, run:</p>
<pre><code class="lang-bash">docker images
</code></pre>
<p>You should see mongo and mongo-express listed in the output.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762951088081/06345eb1-80c9-4fcd-8585-5ff309ed2779.png" alt="docker images command showing both mongo db database and mongo express images verifying they have been installed by docker on your project" class="image--center mx-auto" width="1164" height="740" loading="lazy"></p>
<p>Now that both images are downloaded, the next step is to run the containers to make sure MongoDB is up and accessible, and then connect it to Mongo Express so we can manage it through the browser.</p>
<p>Before we do that, let’s briefly look at how these two containers will communicate.</p>
<h3 id="heading-docker-network">Docker Network</h3>
<p>When MongoDB and Mongo Express run in separate containers, they need a way to talk to each other. Docker handles this using something called a <strong>Docker Network,</strong> a virtual bridge that lets containers communicate securely without exposing internal ports to the outside world.</p>
<p>When you run containers in Docker, it automatically creates an isolated network for them. Think of it like a private space where your containers can talk to each other safely without exposing everything to the outside world.</p>
<p>For example, if our MongoDB container and Mongo Express container are on the same Docker network, they can communicate just by using their container names (like <code>mongo</code> or <code>mongo-express</code>). You don’t need to use <code>localhost</code> or port numbers, as Docker handles that part internally.</p>
<p>But anything outside the Docker network (like your host machine or a Node.js app) connects through the exposed ports.</p>
<p>So later, when we package our entire application, the Node.js backend, MongoDB, Mongo Express, and even the frontend (<code>index.html</code>) into Docker, all these containers will interact smoothly through the Docker network. The browser on your computer will then connect to your Node.js app using the host address and port we have exposed.</p>
<p>By default, Docker already provides a few built-in networks. You can see them by running:</p>
<pre><code class="lang-bash">docker network ls
</code></pre>
<p>You will get something like this:</p>
<pre><code class="lang-bash">NETWORK ID     NAME      DRIVER    SCOPE
712a7144f1a0   bridge    bridge    <span class="hljs-built_in">local</span>
4ae27eedea5b   host      host      <span class="hljs-built_in">local</span>
4806000201ce   none      null      <span class="hljs-built_in">local</span>
</code></pre>
<p>These are automatically created by Docker. You don’t need to worry too much about them right now – we will just focus on creating our own custom network.</p>
<p>For our setup, we will create a separate network that both MongoDB and Mongo Express can share. Let’s call it mongo-network:</p>
<pre><code class="lang-bash">docker network create mongo-network
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762953968310/bdf51a4e-1986-48a4-922b-6f312ff99414.png" alt="mongo-network created with docker network create mongo-network then to see it in the list run docker network ls" class="image--center mx-auto" width="1144" height="740" loading="lazy"></p>
<h2 id="heading-how-to-run-the-mongo-container">How to Run the Mongo Container</h2>
<p>To make sure our MongoDB and Mongo Express containers can communicate, we need to run them inside the same Docker network. That’s why we created mongo-network earlier.</p>
<p>Let’s start with MongoDB. Remember, the <code>docker run</code> command is used to start a container from an image. In this case, we will run the official MongoDB image and attach it to our network.</p>
<p>We will also expose the default MongoDB port 27017 so it’s accessible from outside the container, and set up environment variables for the root username and password.</p>
<p>Here is the command:</p>
<pre><code class="lang-bash">docker run -p 27017:27017 -d \
  -e MONGO_INITDB_ROOT_USERNAME=admin \
  -e MONGO_INITDB_ROOT_PASSWORD=password \
  --name mongo \
  --network mongo-network \
  mongo
</code></pre>
<p>Here’s what each part does:</p>
<ul>
<li><p><code>-p 27017:27017</code> maps the container’s MongoDB port to your host machine.</p>
</li>
<li><p><code>-d</code> runs the container in detached mode (in the background).</p>
</li>
<li><p><code>-e</code> sets environment variables for the database’s root credentials.</p>
</li>
<li><p><code>--name mongo</code> gives the container a custom name for easier reference.</p>
</li>
<li><p><code>--network mongo-network</code> connects the container to the network we created.</p>
</li>
</ul>
<p>Once it runs successfully, your MongoDB instance will be up and running inside the Docker network, ready for other containers like Mongo Express to connect to it.</p>
<p>After creating your MongoDB container, you can easily check if it’s running and healthy.</p>
<p>First, run <code>docker ps</code> to see all active containers. You should see your MongoDB container (<code>mongo</code>) listed with its port <code>27017</code> exposed. To get more details about what’s happening inside the container, you can check its logs using <code>docker logs mongo</code> or, if you prefer, by using the container ID (for example: <code>docker logs 7abb38175ae28</code>). The logs will show startup messages from MongoDB, and you should look for lines indicating that the database started successfully and is ready to accept connections.</p>
<p>This is a quick way to verify that everything is working correctly before connecting other services, like Mongo Express, to it.</p>
<pre><code class="lang-bash">docker ps
</code></pre>
<p>This will list all <strong>running containers</strong>. You should see your MongoDB container (<code>mongo</code>) with its port <code>27017</code> exposed.</p>
<pre><code class="lang-bash">docker logs mongo or the id of the container e.g docker logs 7abb38175ae283429354609866c8d97521f37b535c475ae448295f8fc0ed947f
</code></pre>
<p>This will show startup messages. Look for lines indicating MongoDB started successfully and is ready to accept connections.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762956236708/44dbe331-b736-4526-8dae-019150b618d8.png" alt="checking if the mongo container is running" class="image--center mx-auto" width="2504" height="222" loading="lazy"></p>
<h2 id="heading-how-to-run-the-mongo-express-container">How to Run the Mongo Express Container</h2>
<p>Now that MongoDB is up and running, we can run Mongo Express, which is a web-based interface to manage and view your MongoDB databases. We will connect it to the same network (<code>mongo-network</code>) so it can communicate with MongoDB.</p>
<p>Here’s the command:</p>
<pre><code class="lang-bash">docker run -d \
  -e ME_CONFIG_MONGODB_ADMINUSERNAME=admin \
  -e ME_CONFIG_MONGODB_ADMINPASSWORD=password \
  -e ME_CONFIG_MONGODB_SERVER=mongo \
  --name mongo-express \
  --network mongo-network \
  -p 8081:8081 \
  mongo-express
</code></pre>
<p>Here’s what each part does:</p>
<ul>
<li><p><code>-d</code> runs the container in detached mode (in the background).</p>
</li>
<li><p><code>-e ME_CONFIG_MONGODB_ADMINUSERNAME=admin</code> sets the MongoDB admin username for Mongo Express to use.</p>
</li>
<li><p><code>-e ME_CONFIG_MONGODB_ADMINPASSWORD=password</code> sets the corresponding MongoDB password.</p>
</li>
<li><p><code>-e ME_CONFIG_MONGODB_SERVER=mongo</code> tells Mongo Express which MongoDB server to connect to. Here we use the container name <code>mongo</code> because both containers are on the same network.</p>
</li>
<li><p><code>--name mongo-express</code> gives the container a friendly name for easier reference.</p>
</li>
<li><p><code>--network mongo-network</code> connects the container to the same Docker network as MongoDB so they can talk to each other.</p>
</li>
<li><p><code>-p 8081:8081</code> exposes the Mongo Express web interface on port <code>8081</code> of your host machine.</p>
</li>
<li><p><code>mongo-express</code> the name of the Docker image we’re running.</p>
</li>
</ul>
<p>Once the container is running, you can open your browser and visit <code>http://localhost:8081</code> to access Mongo Express and interact with your MongoDB instance.</p>
<p>For more details about the available environment variables and options, you can check the official Docker Hub page for Mongo Express <a target="_blank" href="https://hub.docker.com/_/mongo-express">here</a>.</p>
<p>Before opening your browser at <a target="_blank" href="http://localhost:8081"><code>http://localhost:8081</code></a>, it’s a good idea to check if the Mongo Express container is running properly. You can do this by viewing its logs:</p>
<pre><code class="lang-bash">docker logs &lt;container-id&gt;
<span class="hljs-comment"># or</span>
docker logs mongo-express
</code></pre>
<p>You should see output similar to this:</p>
<pre><code class="lang-bash">Waiting <span class="hljs-keyword">for</span> mongo:27017...
No custom config.js found, loading config.default.js
Welcome to mongo-express 1.0.2
------------------------
Mongo Express server listening at http://0.0.0.0:8081
Server is open to allow connections from anyone (0.0.0.0)
basicAuth credentials are <span class="hljs-string">"admin:pass"</span>, it is recommended you change this <span class="hljs-keyword">in</span> your config.js!
</code></pre>
<p>This confirms that Mongo Express is up and running and ready to connect to your MongoDB instance.</p>
<p>Take note of the basicAuth credentials shown in the logs (admin:pass). If these credentials are present, you’ll need to use them when accessing Mongo Express from your browser. Later, you can change them in a custom config.js file for better security.</p>
<p>Once everything looks good in the logs, you can safely visit <a target="_blank" href="http://localhost:8081"><code>http://localhost:8081</code></a> to access the Mongo Express interface.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1762957766334/f64b1f06-87a8-4ffb-b905-47e1871cca64.png" alt="mongo-express interface from http://localhost:8081 " class="image--center mx-auto" width="2880" height="1430" loading="lazy"></p>
<p>If your browser asks for a username and password when accessing Mongo Express, use the basicAuth credentials shown in the container logs:</p>
<pre><code class="lang-bash">Username: admin
Password: pass
</code></pre>
<p>These are the default credentials, and it’s <strong>strongly recommended</strong> to change them later in a custom <code>config.js</code> file for better security.</p>
<p>When you open Mongo Express, you will notice some default databases already created. For this project, we will create a new database called todos. Once it’s created, your Node.js application can connect to this database to store and retrieve data.</p>
<h2 id="heading-how-to-connect-nodejs-to-mongodb">How to Connect Node.js to MongoDB</h2>
<p>You already have MongoDB running inside a Docker container (mongo). The container exposes the default MongoDB port 27017 to the host, so any process on your laptop/desktop can reach it via <a target="_blank" href="http://localhost:27017">localhost:27017</a>.</p>
<p><strong>Important:</strong> The Node.js app is <strong>outside Docker</strong> (it’s just a regular node server.js process you start from your terminal).</p>
<p>Because the app is external, we <strong>must use</strong> <a target="_blank" href="http://localhost"><strong>localhost</strong></a> (or 127.0.0.1) as the host name – <strong>not</strong> the container name mongo.</p>
<p>Once we later containerise the Node.js app and put it on the same Docker network, we’ll switch the host to mongo. For now, keep it <a target="_blank" href="http://localhost">localhost</a>.</p>
<h3 id="heading-nodejs-backend">Node.js Backend</h3>
<p>Here’s a version of our <code>server.js</code> using MongoDB:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> express = <span class="hljs-built_in">require</span>(<span class="hljs-string">"express"</span>);
<span class="hljs-keyword">const</span> multer = <span class="hljs-built_in">require</span>(<span class="hljs-string">"multer"</span>);
<span class="hljs-keyword">const</span> path = <span class="hljs-built_in">require</span>(<span class="hljs-string">"path"</span>);
<span class="hljs-keyword">const</span> fs = <span class="hljs-built_in">require</span>(<span class="hljs-string">"fs"</span>);
<span class="hljs-keyword">const</span> { MongoClient, ObjectId } = <span class="hljs-built_in">require</span>(<span class="hljs-string">"mongodb"</span>);

<span class="hljs-keyword">const</span> app = express();
<span class="hljs-keyword">const</span> PORT = <span class="hljs-number">3000</span>;

<span class="hljs-comment">// Host = localhost  →  talks to the MongoDB container via the exposed port</span>
<span class="hljs-comment">// Port = 27017      →  default MongoDB port</span>
<span class="hljs-comment">// User / Pass       →  admin / password (the credentials you gave the container)</span>
<span class="hljs-keyword">const</span> mongoUrl = <span class="hljs-string">"mongodb://admin:password@localhost:27017"</span>;
<span class="hljs-keyword">const</span> dbName = <span class="hljs-string">"todos"</span>;
<span class="hljs-keyword">let</span> db;

MongoClient.connect(mongoUrl)
  .then(<span class="hljs-function">(<span class="hljs-params">client</span>) =&gt;</span> {
    db = client.db(dbName);
    <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Connected to MongoDB →"</span>, dbName);
  })
  .catch(<span class="hljs-function">(<span class="hljs-params">err</span>) =&gt;</span> <span class="hljs-built_in">console</span>.error(<span class="hljs-string">"MongoDB connection error:"</span>, err));

<span class="hljs-keyword">const</span> uploadDir = path.join(__dirname, <span class="hljs-string">"uploads"</span>);
<span class="hljs-keyword">if</span> (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir);

<span class="hljs-keyword">const</span> storage = multer.diskStorage({
  <span class="hljs-attr">destination</span>: <span class="hljs-function">(<span class="hljs-params">req, file, cb</span>) =&gt;</span> cb(<span class="hljs-literal">null</span>, uploadDir),
  <span class="hljs-attr">filename</span>: <span class="hljs-function">(<span class="hljs-params">req, file, cb</span>) =&gt;</span> {
    <span class="hljs-keyword">const</span> unique = <span class="hljs-built_in">Date</span>.now() + <span class="hljs-string">"-"</span> + <span class="hljs-built_in">Math</span>.round(<span class="hljs-built_in">Math</span>.random() * <span class="hljs-number">1e9</span>);
    cb(<span class="hljs-literal">null</span>, <span class="hljs-string">"photo-"</span> + unique + path.extname(file.originalname));
  },
});
<span class="hljs-keyword">const</span> upload = multer({ storage });

app.use(express.static(__dirname));
app.use(<span class="hljs-string">"/uploads"</span>, express.static(uploadDir));
app.use(express.json());
app.use(express.urlencoded({ <span class="hljs-attr">extended</span>: <span class="hljs-literal">true</span> }));

app.get(<span class="hljs-string">"/todos"</span>, <span class="hljs-keyword">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">const</span> todos = <span class="hljs-keyword">await</span> db.collection(<span class="hljs-string">"todos"</span>).find().toArray();
  res.json(todos);
});

app.post(<span class="hljs-string">"/todos"</span>, upload.single(<span class="hljs-string">"photo"</span>), <span class="hljs-keyword">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">const</span> text = req.body.text?.trim();
  <span class="hljs-keyword">if</span> (!text) <span class="hljs-keyword">return</span> res.status(<span class="hljs-number">400</span>).json({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Text required"</span> });

  <span class="hljs-keyword">const</span> todo = {
    text,
    <span class="hljs-attr">image</span>: req.file ? <span class="hljs-string">`/uploads/<span class="hljs-subst">${req.file.filename}</span>`</span> : <span class="hljs-literal">null</span>,
    <span class="hljs-attr">createdAt</span>: <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>(),
  };

  <span class="hljs-keyword">const</span> result = <span class="hljs-keyword">await</span> db.collection(<span class="hljs-string">"todos"</span>).insertOne(todo);
  todo._id = result.insertedId;
  res.json(todo);
});

<span class="hljs-comment">// Start server</span>
app.listen(PORT, <span class="hljs-function">() =&gt;</span> {
  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">`Server → http://localhost:<span class="hljs-subst">${PORT}</span>`</span>);
});
</code></pre>
<h3 id="heading-frontend"><strong>Frontend</strong></h3>
<p><code>index.html</code>:</p>
<pre><code class="lang-xml"><span class="hljs-meta">&lt;!DOCTYPE <span class="hljs-meta-keyword">html</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">html</span> <span class="hljs-attr">lang</span>=<span class="hljs-string">"en"</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">head</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">meta</span> <span class="hljs-attr">charset</span>=<span class="hljs-string">"UTF-8"</span> /&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">title</span>&gt;</span>Todo + Image<span class="hljs-tag">&lt;/<span class="hljs-name">title</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">style</span>&gt;</span><span class="css">
      <span class="hljs-selector-tag">body</span> {
        <span class="hljs-attribute">font-family</span>: sans-serif;
        <span class="hljs-attribute">margin</span>: <span class="hljs-number">2rem</span>;
        <span class="hljs-attribute">max-width</span>: <span class="hljs-number">800px</span>;
      }
      <span class="hljs-selector-class">.todo</span> {
        <span class="hljs-attribute">border</span>: <span class="hljs-number">1px</span> solid <span class="hljs-number">#ccc</span>;
        <span class="hljs-attribute">padding</span>: <span class="hljs-number">1rem</span>;
        <span class="hljs-attribute">margin-bottom</span>: <span class="hljs-number">1rem</span>;
        <span class="hljs-attribute">border-radius</span>: <span class="hljs-number">8px</span>;
      }
      <span class="hljs-selector-class">.todo</span> <span class="hljs-selector-tag">img</span> {
        <span class="hljs-attribute">max-height</span>: <span class="hljs-number">150px</span>;
        <span class="hljs-attribute">margin-top</span>: <span class="hljs-number">0.5rem</span>;
      }
      <span class="hljs-selector-class">.error</span> {
        <span class="hljs-attribute">color</span>: red;
      }
      <span class="hljs-selector-tag">input</span><span class="hljs-selector-attr">[type=<span class="hljs-string">"text"</span>]</span> {
        <span class="hljs-attribute">width</span>: <span class="hljs-number">100%</span>;
        <span class="hljs-attribute">padding</span>: <span class="hljs-number">0.5rem</span>;
        <span class="hljs-attribute">margin-bottom</span>: <span class="hljs-number">0.5rem</span>;
      }
      <span class="hljs-selector-id">#preview</span> {
        <span class="hljs-attribute">max-width</span>: <span class="hljs-number">300px</span>;
        <span class="hljs-attribute">margin-top</span>: <span class="hljs-number">0.5rem</span>;
        <span class="hljs-attribute">display</span>: none;
      }
    </span><span class="hljs-tag">&lt;/<span class="hljs-name">style</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">head</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">body</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">h1</span>&gt;</span>Todo List with Images<span class="hljs-tag">&lt;/<span class="hljs-name">h1</span>&gt;</span>

    <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"addForm"</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"text"</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"textInput"</span> <span class="hljs-attr">placeholder</span>=<span class="hljs-string">"What needs to be done?"</span> /&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"file"</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"imageInput"</span> <span class="hljs-attr">accept</span>=<span class="hljs-string">"image/*"</span> /&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">img</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"preview"</span> <span class="hljs-attr">alt</span>=<span class="hljs-string">"preview"</span> /&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"addBtn"</span>&gt;</span>Add Todo<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">p</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"status"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

    <span class="hljs-tag">&lt;<span class="hljs-name">h2</span>&gt;</span>Todos<span class="hljs-tag">&lt;/<span class="hljs-name">h2</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"todos"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

    <span class="hljs-tag">&lt;<span class="hljs-name">script</span>&gt;</span><span class="javascript">
      <span class="hljs-keyword">const</span> $ = <span class="hljs-built_in">document</span>.querySelector.bind(<span class="hljs-built_in">document</span>);

      <span class="hljs-keyword">const</span> textInput = $(<span class="hljs-string">"#textInput"</span>);
      <span class="hljs-keyword">const</span> imageInput = $(<span class="hljs-string">"#imageInput"</span>);
      <span class="hljs-keyword">const</span> preview = $(<span class="hljs-string">"#preview"</span>);
      <span class="hljs-keyword">const</span> addBtn = $(<span class="hljs-string">"#addBtn"</span>);
      <span class="hljs-keyword">const</span> status = $(<span class="hljs-string">"#status"</span>);
      <span class="hljs-keyword">const</span> todosDiv = $(<span class="hljs-string">"#todos"</span>);

      imageInput.addEventListener(<span class="hljs-string">"change"</span>, <span class="hljs-function">() =&gt;</span> {
        <span class="hljs-keyword">const</span> file = imageInput.files[<span class="hljs-number">0</span>];
        <span class="hljs-keyword">if</span> (!file) {
          preview.style.display = <span class="hljs-string">"none"</span>;
          <span class="hljs-keyword">return</span>;
        }
        <span class="hljs-keyword">const</span> reader = <span class="hljs-keyword">new</span> FileReader();
        reader.onload = <span class="hljs-function">(<span class="hljs-params">e</span>) =&gt;</span> {
          preview.src = e.target.result;
          preview.style.display = <span class="hljs-string">"block"</span>;
        };
        reader.readAsDataURL(file);
      });

      addBtn.addEventListener(<span class="hljs-string">"click"</span>, <span class="hljs-keyword">async</span> () =&gt; {
        <span class="hljs-keyword">const</span> text = textInput.value.trim();
        <span class="hljs-keyword">if</span> (!text) {
          status.textContent = <span class="hljs-string">"Please enter a todo text."</span>;
          status.className = <span class="hljs-string">"error"</span>;
          <span class="hljs-keyword">return</span>;
        }

        <span class="hljs-keyword">const</span> form = <span class="hljs-keyword">new</span> FormData();
        form.append(<span class="hljs-string">"text"</span>, text);
        <span class="hljs-keyword">if</span> (imageInput.files[<span class="hljs-number">0</span>]) form.append(<span class="hljs-string">"photo"</span>, imageInput.files[<span class="hljs-number">0</span>]);

        <span class="hljs-keyword">try</span> {
          <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">"/todos"</span>, { <span class="hljs-attr">method</span>: <span class="hljs-string">"POST"</span>, <span class="hljs-attr">body</span>: form });
          <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> res.json();
          <span class="hljs-keyword">if</span> (!res.ok) <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(data.error || <span class="hljs-string">"failed"</span>);
          status.textContent = <span class="hljs-string">"Todo added!"</span>;
          status.className = <span class="hljs-string">""</span>;
          textInput.value = <span class="hljs-string">""</span>;
          imageInput.value = <span class="hljs-string">""</span>;
          preview.style.display = <span class="hljs-string">"none"</span>;
          loadTodos(); <span class="hljs-comment">// refresh list</span>
        } <span class="hljs-keyword">catch</span> (err) {
          status.textContent = <span class="hljs-string">"Error: "</span> + err.message;
          status.className = <span class="hljs-string">"error"</span>;
        }
      });

      <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">loadTodos</span>(<span class="hljs-params"></span>) </span>{
        <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">"/todos"</span>);
        <span class="hljs-keyword">const</span> todos = <span class="hljs-keyword">await</span> res.json();
        todosDiv.innerHTML = <span class="hljs-string">""</span>;
        todos.forEach(<span class="hljs-function">(<span class="hljs-params">t</span>) =&gt;</span> {
          <span class="hljs-keyword">const</span> div = <span class="hljs-built_in">document</span>.createElement(<span class="hljs-string">"div"</span>);
          div.className = <span class="hljs-string">"todo"</span>;
          div.innerHTML = <span class="hljs-string">`&lt;strong&gt;<span class="hljs-subst">${escapeHtml(t.text)}</span>&lt;/strong&gt;`</span>;
          <span class="hljs-keyword">if</span> (t.image) {
            div.innerHTML += <span class="hljs-string">`&lt;br&gt;&lt;img src="<span class="hljs-subst">${t.image}</span>" alt="todo image"&gt;`</span>;
          }
          todosDiv.appendChild(div);
        });
      }

      <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">escapeHtml</span>(<span class="hljs-params">s</span>) </span>{
        <span class="hljs-keyword">const</span> div = <span class="hljs-built_in">document</span>.createElement(<span class="hljs-string">"div"</span>);
        div.textContent = s;
        <span class="hljs-keyword">return</span> div.innerHTML;
      }

      loadTodos();
    </span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">body</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">html</span>&gt;</span>
</code></pre>
<p>Now your Node.js app can connect to the MongoDB container running in Docker. Since the app is running outside Docker for now, it connects through <code>localhost:27017</code> using the credentials you set (<code>admin</code> / <code>password</code>).</p>
<p>Once connected, your Node.js backend stores and retrieves todos directly from the <code>todos</code> database in MongoDB, replacing the in-memory array. Later, if you containerize the Node.js app and put it on the same Docker network as MongoDB, you can switch the host from <code>localhost</code> to the container name <code>mongo</code>. we are getting there</p>
<p>You can get the full backend and frontend code ready to run and tweak it for your setup here: <a target="_blank" href="https://github.com/Oghenekparobo/docker_tut_js/tree/mongodb-connection">GitHub repo</a>.</p>
<h2 id="heading-how-to-use-docker-compose">How to Use Docker Compose</h2>
<p>So we now have our Node.js app connected to MongoDB and Mongo Express, both running inside containers. We’ve created the network, started the containers, and everything is talking to each other perfectly.</p>
<p>But let’s be honest: typing out all those long <code>docker run</code> commands every time can get tedious. You probably want a simpler, cleaner way to spin everything up with just one command. That’s where <strong>Docker Compose</strong> comes in.</p>
<p>Docker Compose is a tool that lets you define and run multi-container applications with a single command. Instead of manually running multiple <code>docker run</code> commands, you describe your setup in a simple <code>docker-compose.yml</code> file, specifying each service (like your Node.js app, MongoDB, and Mongo Express), their configurations, environment variables, and shared networks.</p>
<p>Basically, it lets you manage multiple containers as one project, easy to start, stop, and maintain with a single file and a single command.</p>
<p>The standard naming convention is <code>docker-compose.yml</code> (or <code>docker-compose.yaml</code>. Both work, but <code>.yml</code> is more common).</p>
<p>Docker automatically detects it when you run:</p>
<pre><code class="lang-xml">docker compose up
</code></pre>
<p>So yeah, stick with <code>docker-compose.yml</code> for convention.</p>
<p>Now, to run the containers for MongoDB and Mongo Express, we can use the following two commands, respectively:</p>
<pre><code class="lang-xml"># MongoDB container
docker run -p 27017:27017 -d \
  -e MONGO_INITDB_ROOT_USERNAME=admin \
  -e MONGO_INITDB_ROOT_PASSWORD=password \
  --name mongo \
  --network mongo-network \
  mongo

# Mongo Express container
docker run -d \
  -e ME_CONFIG_MONGODB_ADMINUSERNAME=admin \
  -e ME_CONFIG_MONGODB_ADMINPASSWORD=password \
  -e ME_CONFIG_MONGODB_SERVER=mongo \
  --name mongo-express \
  --network mongo-network \
  -p 8081:8081 \
  mongo-express
</code></pre>
<p>Now, instead of typing these long commands every time, we will combine them and run everything at once using a <strong>Docker Compose file</strong>.</p>
<p>The <code>docker-compose.yml</code> file will be located at the root of our Node.js project.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763032152636/7fc026ba-d593-4097-a34c-945b398f2aeb.png" alt="docker-composer.yml file in the root of the project" class="image--center mx-auto" width="2880" height="1612" loading="lazy"></p>
<p>Here’s how our <code>docker-compose.yml</code> file looks:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">"3.8"</span>

<span class="hljs-attr">services:</span>
  <span class="hljs-attr">mongodb:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">mongo</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">mongo</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"27017:27017"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">MONGO_INITDB_ROOT_USERNAME:</span> <span class="hljs-string">admin</span>
      <span class="hljs-attr">MONGO_INITDB_ROOT_PASSWORD:</span> <span class="hljs-string">password</span>

  <span class="hljs-attr">mongo-express:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">mongo-express</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">mongo-express</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"8081:8081"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">ME_CONFIG_MONGODB_ADMINUSERNAME:</span> <span class="hljs-string">admin</span>
      <span class="hljs-attr">ME_CONFIG_MONGODB_ADMINPASSWORD:</span> <span class="hljs-string">password</span>
      <span class="hljs-attr">ME_CONFIG_MONGODB_SERVER:</span> <span class="hljs-string">mongodb</span>
    <span class="hljs-attr">depends_on:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">mongodb</span>
</code></pre>
<p>Let’s break down what’s going on here:</p>
<ul>
<li><p><code>version: "3.8"</code>: This defines the <strong>Compose file version</strong>. Each version has slightly different syntax rules and features. Version 3.8 is modern and works with the latest Docker Engine.</p>
</li>
<li><p><code>services:</code>: All the containers we want to run are defined here. In our case, two services: <code>mongodb</code> and <code>mongo-express</code>.</p>
</li>
</ul>
<p><strong>MongoDB service:</strong></p>
<ul>
<li><p><code>image: mongo</code> pulls the official MongoDB image from Docker Hub.</p>
</li>
<li><p><code>container_name: mongo</code> gives the container a friendly name.</p>
</li>
<li><p><code>ports: "27017:27017"</code> exposes MongoDB’s default port to our host, so Node.js or other apps can connect.</p>
</li>
<li><p><code>environment:</code> sets up the root username and password for MongoDB.</p>
</li>
</ul>
<p><strong>Mongo Express service:</strong></p>
<ul>
<li><p><code>image: mongo-express</code> is the official Mongo Express image.</p>
</li>
<li><p><code>container_name: mongo-express</code> is a friendly name for easier reference.</p>
</li>
<li><p><code>ports: "8081:8081"</code> exposes Mongo Express web interface on host port 8081.</p>
</li>
<li><p><code>environment:</code> let’s Mongo Express know how to connect to MongoDB (username, password, host).</p>
</li>
<li><p><code>depends_on: - mongodb</code> ensures MongoDB starts first, so Mongo Express can connect immediately.</p>
</li>
</ul>
<h3 id="heading-why-use-docker-compose">Why Use Docker Compose?</h3>
<ul>
<li><strong>Single command</strong>: Instead of running multiple long <code>docker run</code> commands, just run:</li>
</ul>
<pre><code class="lang-bash">docker compose up -d
</code></pre>
<ul>
<li><p><strong>Automatic networking</strong>: Compose creates a default network so services can communicate using their <strong>service names</strong> (<code>mongodb</code> In our case)</p>
</li>
<li><p><strong>Easier maintenance</strong>: You can stop, start, or rebuild all services with simple commands.</p>
</li>
</ul>
<p>Before we run our new <code>docker-compose.yml</code>, it’s important to make sure no conflicting containers are running. Remember, we already had MongoDB and Mongo Express running from the previous <code>docker run</code> commands.</p>
<p>To avoid conflicts (like ports already in use), we should stop and remove any running containers first.</p>
<p>Here’s how:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># List all running containers</span>
docker ps

<span class="hljs-comment"># Stop a specific container (replace &lt;container_name&gt; with mongo or mongo-express)</span>
docker stop mongo
docker stop mongo-express

<span class="hljs-comment"># Remove the stopped containers</span>
docker rm mongo
docker rm mongo-express

<span class="hljs-comment"># Optional: stop and remove all running containers at once</span>
docker stop $(docker ps -q)
docker rm $(docker ps -a -q)
</code></pre>
<ul>
<li><p><code>docker ps</code> shows currently running containers.</p>
</li>
<li><p><code>docker stop &lt;name&gt;</code> stops a container gracefully.</p>
</li>
<li><p><code>docker rm &lt;name&gt;</code> removes the container from Docker.</p>
</li>
<li><p><code>docker stop $(docker ps -q)</code> stops all running containers.</p>
</li>
<li><p><code>docker rm $(docker ps -a -q)</code> removes all containers (running or stopped).</p>
</li>
</ul>
<p>Once all previous containers are stopped and removed, we’re ready to run our Docker Compose setup safely without conflicts.</p>
<p>Now that all previous containers are stopped, we can start MongoDB and Mongo Express together using our <code>docker-compose.yml</code> file.</p>
<p>From the root of your Node.js project (where the <code>docker-compose.yml</code> file is located), run:</p>
<pre><code class="lang-bash">docker compose up -d
</code></pre>
<p>Here’s what this does:</p>
<ul>
<li><p><code>docker compose</code> tells Docker to use Compose.</p>
</li>
<li><p><code>up</code> builds (if needed) and starts all the services defined in the Compose file.</p>
</li>
<li><p><code>-d</code> runs the containers in <strong>detached mode</strong>, meaning they run in the background.</p>
</li>
</ul>
<p>After running this command, Docker will start both MongoDB and Mongo Express, connect them on the same internal network, and expose the ports we defined (<code>27017</code> for MongoDB and <code>8081</code> for Mongo Express).</p>
<p>If everything worked correctly, after running:</p>
<pre><code class="lang-bash">docker compose up -d
</code></pre>
<p>You should see output similar to this:</p>
<pre><code class="lang-bash">[+] Running 3/3
 ✔ Network docker_tut_default  Created                                                                                               0.0s 
 ✔ Container mongo             Started                                                                                               0.6s 
 ✔ Container mongo-express     Started                                                                                               0.8s 
stephenjohnson@Oghenekparobo docker_tut %
</code></pre>
<p>What this means:</p>
<ul>
<li><p><code>Network docker_tut_default Created</code>: Docker Compose automatically creates a network for your services so they can communicate with each other.</p>
</li>
<li><p><code>Container mongo Started</code>: Your MongoDB container is running.</p>
</li>
<li><p><code>Container mongo-express Started</code>: Your Mongo Express container is running.</p>
</li>
</ul>
<p>You can confirm that the containers are running by using:</p>
<pre><code class="lang-bash">docker ps
</code></pre>
<p>This will list all active containers. You should see both <code>mongo</code> and <code>mongo-express</code> with their respective ports (<code>27017</code> for MongoDB and <code>8081</code> for Mongo Express) exposed.</p>
<ul>
<li><p>To access Mongo Express, open your browser and go to <a target="_blank" href="http://localhost:8081">http://localhost:8081</a> to interact with MongoDB through the web interface.</p>
</li>
<li><p>To access MongoDB, your Node.js app can connect to MongoDB at <code>localhost:27017</code> using the credentials you set in the Compose file.</p>
</li>
</ul>
<p>Compared to running long <code>docker run</code> commands for each container, using Docker Compose is easier because:</p>
<ul>
<li><p>Starts multiple containers with one command.</p>
</li>
<li><p>Automatically sets up networking between containers.</p>
</li>
<li><p>Makes it easier to stop, remove, or rebuild containers later.</p>
</li>
</ul>
<p>In short, Docker Compose simplifies and organizes everything, making it much easier to manage your development environment.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763034021801/c4f806d2-080f-4b52-9f36-7b2044a7f8c5.png" alt="docker compose up -d succesfuly created the containers and docker ps shows the containers" class="image--center mx-auto" width="1962" height="746" loading="lazy"></p>
<p>At this stage, it’s important to know that any data you add to MongoDB is temporary. If you stop or remove your containers and then start them again, you will notice that all your data is gone. This happens because data inside a container isn’t persistent by default.</p>
<p>Don’t worry, this is expected, and we’ll cover how to make data persistent later in the tutorial when we introduce <strong>Docker volumes.</strong> For now, just be aware that each time you restart your containers, MongoDB starts fresh with no previous data.</p>
<p>You can get a full sample, including the Dockerfile <strong>and</strong> the docker‑compose file, <a target="_blank" href="https://github.com/Oghenekparobo/docker_tut_js/tree/docker-compose">here</a>.</p>
<h2 id="heading-how-to-build-our-own-docker-image">How to Build Our Own Docker Image</h2>
<p>Now that we have tested our Node.js application locally and seen it working perfectly with MongoDB and Mongo Express, the next step is preparing it for deployment.</p>
<p>Running the app directly on our machine works fine for development, but it’s not practical when we want to move it to another environment or server. By creating a Docker image, we can package the application together with all its dependencies, configuration, and environment setup into a single, portable unit. This image can then run anywhere Docker is installed, ensuring our app works the same way across development, testing, and production.</p>
<p>In short, building a Docker image is how we containerize our app and make it deployment-ready.</p>
<p>In order to containerize our Todo app, we need a <strong>Dockerfile</strong>. A Dockerfile is essentially a blueprint that tells Docker how to build an image for our application. It defines the base environment, copies our application code, installs dependencies, and specifies how the app should start. With this blueprint, Docker can create a consistent image that behaves the same way on any machine, making our Node.js app fully portable and ready for deployment.</p>
<p>In our Dockerfile, notice the capital <code>D</code>, which is the standard naming convention. Place this file in the <strong>root directory</strong> of your Node.js project. In simple projects like ours, our main app file (like <code>server.js</code> or <code>index.js</code>) is usually in the root too, along with <code>package.json</code>. Docker will use this file as a blueprint to build a container image of your application.</p>
<p>If your main app file is inside a subfolder, that’s fine too. Just make sure the Dockerfile’s <code>COPY</code> and <code>CMD</code> commands point to the correct location. The important thing is that the Dockerfile lives in the root so Docker knows where to start building your app.</p>
<p>Here’s how the contents of our Dockerfile look:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># Use full Node 18 (Debian-based)</span>
<span class="hljs-string">FROM</span> <span class="hljs-string">node:18</span>

<span class="hljs-comment"># Set environment variables</span>
<span class="hljs-string">ENV</span> <span class="hljs-string">MONGO_DB_USERNAME=admin</span> <span class="hljs-string">\</span>
    <span class="hljs-string">MONGO_DB_PASSWORD=password</span>

<span class="hljs-comment"># Set working directory</span>
<span class="hljs-string">WORKDIR</span> <span class="hljs-string">/home/app</span>

<span class="hljs-comment"># Copy package files</span>
<span class="hljs-string">COPY</span> <span class="hljs-string">package*.json</span> <span class="hljs-string">./</span>

<span class="hljs-comment"># Install dependencies</span>
<span class="hljs-string">RUN</span> <span class="hljs-string">npm</span> <span class="hljs-string">install</span>

<span class="hljs-comment"># Copy source code</span>
<span class="hljs-string">COPY</span> <span class="hljs-string">.</span> <span class="hljs-string">.</span>

<span class="hljs-comment"># Expose port</span>
<span class="hljs-string">EXPOSE</span> <span class="hljs-number">3000</span>

<span class="hljs-comment"># Start the app</span>
<span class="hljs-string">CMD</span> [<span class="hljs-string">"node"</span>, <span class="hljs-string">"server.js"</span>]
</code></pre>
<p>Let’s see what’s going on here:</p>
<ul>
<li><p><code>FROM node:13-alpine</code> is the base image for our container. It comes with Node.js installed and is very lightweight, keeping the image small.</p>
</li>
<li><p><code>ENV MONGO_DB_USERNAME=admin \ MONGO_DB_PASSWORD=password</code> sets environment variables inside the container so the Node.js app can connect to MongoDB.</p>
</li>
<li><p><code>WORKDIR /home/app</code> sets the working directory inside the container. All subsequent commands like <code>COPY</code> or <code>RUN</code> will run relative to this folder.</p>
</li>
<li><p><code>COPY . .</code> copies all files from your local project into the container’s working directory. This includes your <code>server.js</code>, <code>package.json</code>, and any other files needed to run the app.</p>
</li>
<li><p><code>RUN npm install</code> installs all the Node.js dependencies listed in <code>package.json</code> inside the container.</p>
</li>
<li><p><code>EXPOSE 3000</code> tells Docker that the container will listen on port 3000, which is the port our Node.js app runs on.</p>
</li>
<li><p><code>CMD ["node", "server.js"]</code> defines the command that runs when the container starts, which launches our Node.js server.</p>
</li>
</ul>
<p>By placing this Dockerfile in the root of your project, Docker knows exactly where to find your app’s files and dependencies. When we build the image, it packages everything inside a portable container that can run anywhere Docker is installed, making deployment straightforward and consistent.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763037649231/a831ccc0-5b84-4e70-82bf-08ef7de85ddf.png" alt="Dockerfile VS CODE Illustration" class="image--center mx-auto" width="2880" height="1382" loading="lazy"></p>
<p>Now that we have our Dockerfile ready, the next step is to build the Docker image for our Node.js app.</p>
<p>To build the image, open your terminal, make sure you are in the root directory of your project (where the Dockerfile is), and run:</p>
<pre><code class="lang-bash">docker build -t todo-app:1.0 .
</code></pre>
<ul>
<li><p><code>todo-app</code> is the name of your image.</p>
</li>
<li><p><code>:1.0</code> is the version tag (you can use any versioning scheme, like <code>1.0</code>, <code>v1</code>, <code>latest</code>, etc.).</p>
</li>
<li><p><code>.</code> tells Docker to use the current folder (root of your project) as the build context.</p>
</li>
</ul>
<p>After running:</p>
<pre><code class="lang-bash">docker build -t todo-app:1.0 .
</code></pre>
<p>Docker reads your Dockerfile, packages your Node.js app with all its dependencies, and creates a Docker image. You can confirm the image exists by running:</p>
<pre><code class="lang-bash">docker images
</code></pre>
<p>You should see output like this:</p>
<pre><code class="lang-bash">REPOSITORY      TAG       IMAGE ID       CREATED          SIZE
todo-app        1.0       d85dd4ed97f9   45 seconds ago   147MB
mongo           latest    1d659cebf5e9   2 weeks ago      894MB
mongo-express   latest    1133e12468c7   20 months ago    182MB
</code></pre>
<p>This shows that your <code>todo-app</code> image has been created successfully, alongside the images for MongoDB and Mongo Express.</p>
<h3 id="heading-running-your-nodejs-app-container">Running Your Node.js App Container</h3>
<p>Now that the image exists, the next step is to run a container from it. A container is basically a running instance of your image. To do this:</p>
<pre><code class="lang-bash">docker run todo-app:1.0
</code></pre>
<p>Here’s what this command does:</p>
<ul>
<li><p><code>docker run</code> starts a new container from the image.</p>
</li>
<li><p><code>todo-app:1.0</code> tells Docker which image to use (the one we just built).</p>
</li>
</ul>
<p>Once this runs, your Node.js app will be live inside a container, separate from your local environment. You can open your browser at <a target="_blank" href="http://localhost:3000"><code>http://localhost:3000</code></a> and see your Todo app working just like it did locally.</p>
<p>To see all running containers, use:</p>
<pre><code class="lang-bash">docker ps
</code></pre>
<p>You’ll see something like:</p>
<pre><code class="lang-bash">CONTAINER ID   IMAGE           COMMAND         CREATED       STATUS       PORTS                  NAMES
d85dd4ed97f9   todo-app:1.0    <span class="hljs-string">"node server.js"</span>  10s ago      Up 10s       0.0.0.0:3000-&gt;3000/tcp   awesome_todo
</code></pre>
<p>This confirms your container is running. If you ever need to stop it:</p>
<pre><code class="lang-bash">docker stop &lt;container-id&gt;
</code></pre>
<h3 id="heading-troubleshooting-errors">Troubleshooting Errors</h3>
<p>We started facing some issues here: when you run <code>docker run todo-app:1.0</code> You'll see an error like this:</p>
<pre><code class="lang-yaml"><span class="hljs-string">Server</span> <span class="hljs-string">→</span> <span class="hljs-string">http://localhost:3000</span> 
<span class="hljs-attr">MongoDB connection error: MongoServerSelectionError:</span> <span class="hljs-string">getaddrinfo</span> <span class="hljs-string">ENOTFOUND</span> <span class="hljs-string">mongodb</span>
    <span class="hljs-string">at</span> <span class="hljs-string">Topology.selectServer</span> <span class="hljs-string">(/home/app/node_modules/mongodb/lib/sdam/topology.js:346:38)</span>
    <span class="hljs-string">...</span>
    [<span class="hljs-string">cause</span>
</code></pre>
<p>especially when you try to perform an operation like creating a todo list.</p>
<p>The error <code>getaddrinfo ENOTFOUND mongodb</code> tells us that your Node.js container can't find MongoDB. Even though MongoDB is running in another container, your app container is isolated and doesn't know how to reach it.</p>
<h4 id="heading-why-this-happens">Why This Happens:</h4>
<p>Remember in our <code>server.js</code>, we connect to MongoDB using:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> mongoUrl = <span class="hljs-string">"mongodb://admin:password@localhost:27017"</span>;
</code></pre>
<p>The problem is with <code>localhost</code>. When you run your app locally on your machine (not in Docker), <code>localhost</code> works perfectly because MongoDB is running on the same machine. But when your app runs inside a Docker container, <code>localhost</code> refers to the container itself, not your host machine or other containers.</p>
<p>Think of it like this:</p>
<ul>
<li><p><strong>Running locally:</strong> Your app and MongoDB are like two people in the same room, <code>localhost</code> works</p>
</li>
<li><p><strong>Running in Docker:</strong> Each container is like a separate room, <code>localhost</code> only refers to that specific room</p>
</li>
</ul>
<h3 id="heading-the-solution"><strong>The Solution</strong></h3>
<p>We need to change the MongoDB connection URL to use the Docker service name instead of <code>localhost</code>. Update your <code>server.js</code> file:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> mongoUrl = <span class="hljs-string">"mongodb://admin:password@localhost:27017"</span>;
</code></pre>
<p>To this:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> mongoUrl = <span class="hljs-string">"mongodb://admin:password@mongodb:27017"</span>;
</code></pre>
<p>Here's the complete updated <code>server.js</code>:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> express = <span class="hljs-built_in">require</span>(<span class="hljs-string">"express"</span>);
<span class="hljs-keyword">const</span> multer = <span class="hljs-built_in">require</span>(<span class="hljs-string">"multer"</span>);
<span class="hljs-keyword">const</span> path = <span class="hljs-built_in">require</span>(<span class="hljs-string">"path"</span>);
<span class="hljs-keyword">const</span> fs = <span class="hljs-built_in">require</span>(<span class="hljs-string">"fs"</span>);
<span class="hljs-keyword">const</span> { MongoClient, ObjectId } = <span class="hljs-built_in">require</span>(<span class="hljs-string">"mongodb"</span>);

<span class="hljs-keyword">const</span> app = express();
<span class="hljs-keyword">const</span> PORT = <span class="hljs-number">3000</span>;

<span class="hljs-comment">// Host = localhost  →  talks to the MongoDB container via the exposed port</span>
<span class="hljs-comment">// Port = 27017      →  default MongoDB port</span>
<span class="hljs-comment">// User / Pass       →  admin / password (the credentials you gave the container)</span>
<span class="hljs-keyword">const</span> mongoUrl = <span class="hljs-string">"mongodb://admin:password@mongodb:27017"</span>;
<span class="hljs-keyword">const</span> dbName = <span class="hljs-string">"todos"</span>;
<span class="hljs-keyword">let</span> db;

MongoClient.connect(mongoUrl)
  .then(<span class="hljs-function">(<span class="hljs-params">client</span>) =&gt;</span> {
    db = client.db(dbName);
    <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Connected to MongoDB →"</span>, dbName);
  })
  .catch(<span class="hljs-function">(<span class="hljs-params">err</span>) =&gt;</span> <span class="hljs-built_in">console</span>.error(<span class="hljs-string">"MongoDB connection error:"</span>, err));

<span class="hljs-keyword">const</span> uploadDir = path.join(__dirname, <span class="hljs-string">"uploads"</span>);
<span class="hljs-keyword">if</span> (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir);

<span class="hljs-keyword">const</span> storage = multer.diskStorage({
  <span class="hljs-attr">destination</span>: <span class="hljs-function">(<span class="hljs-params">req, file, cb</span>) =&gt;</span> cb(<span class="hljs-literal">null</span>, uploadDir),
  <span class="hljs-attr">filename</span>: <span class="hljs-function">(<span class="hljs-params">req, file, cb</span>) =&gt;</span> {
    <span class="hljs-keyword">const</span> unique = <span class="hljs-built_in">Date</span>.now() + <span class="hljs-string">"-"</span> + <span class="hljs-built_in">Math</span>.round(<span class="hljs-built_in">Math</span>.random() * <span class="hljs-number">1e9</span>);
    cb(<span class="hljs-literal">null</span>, <span class="hljs-string">"photo-"</span> + unique + path.extname(file.originalname));
  },
});
<span class="hljs-keyword">const</span> upload = multer({ storage });

app.use(express.static(__dirname));
app.use(<span class="hljs-string">"/uploads"</span>, express.static(uploadDir));
app.use(express.json());
app.use(express.urlencoded({ <span class="hljs-attr">extended</span>: <span class="hljs-literal">true</span> }));

app.get(<span class="hljs-string">"/todos"</span>, <span class="hljs-keyword">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">const</span> todos = <span class="hljs-keyword">await</span> db.collection(<span class="hljs-string">"todos"</span>).find().toArray();
  res.json(todos);
});

app.post(<span class="hljs-string">"/todos"</span>, upload.single(<span class="hljs-string">"photo"</span>), <span class="hljs-keyword">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">const</span> text = req.body.text?.trim();
  <span class="hljs-keyword">if</span> (!text) <span class="hljs-keyword">return</span> res.status(<span class="hljs-number">400</span>).json({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Text required"</span> });

  <span class="hljs-keyword">const</span> todo = {
    text,
    <span class="hljs-attr">image</span>: req.file ? <span class="hljs-string">`/uploads/<span class="hljs-subst">${req.file.filename}</span>`</span> : <span class="hljs-literal">null</span>,
    <span class="hljs-attr">createdAt</span>: <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>(),
  };

  <span class="hljs-keyword">const</span> result = <span class="hljs-keyword">await</span> db.collection(<span class="hljs-string">"todos"</span>).insertOne(todo);
  todo._id = result.insertedId;
  res.json(todo);
});

<span class="hljs-comment">// Start server</span>
app.listen(PORT, <span class="hljs-function">() =&gt;</span> {
  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">`Server → http://localhost:<span class="hljs-subst">${PORT}</span>`</span>);
});
</code></pre>
<h3 id="heading-why-mongodb-works">Why <code>mongodb</code> Works</h3>
<p>The hostname <code>mongodb</code> matches the service name we defined in our <code>docker-compose.yml</code>:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">services:</span>
  <span class="hljs-attr">mongodb:</span>    <span class="hljs-comment"># ← This is the hostname other containers use</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">mongo</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">mongo</span>
    <span class="hljs-string">...</span>
</code></pre>
<p>When containers run in the same Docker Compose network, Docker provides an internal DNS that resolves service names to the correct container IP addresses. So when your app tries to connect to <code>mongodb:27017</code>, Docker automatically routes it to the MongoDB container.</p>
<h3 id="heading-rebuild-your-docker-image">Rebuild Your Docker Image</h3>
<p>Now that we have updated the code, we need to rebuild the Docker image to include this change:</p>
<pre><code class="lang-bash">docker build -t todo-app:1.0 .
``

You should see output confirming the build completed successfully:
```
[+] Building 8.1s (10/10) FINISHED
 =&gt; [internal] load build definition from Dockerfile
 =&gt; =&gt; transferring dockerfile: 443B
 ...
 =&gt; =&gt; naming to docker.io/library/todo-app:1.0
</code></pre>
<h3 id="heading-add-your-app-to-docker-compose">Add Your App to Docker Compose</h3>
<p>Now update your <code>docker-compose.yml</code> file to include the <code>todo-app</code> service:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">"3.8"</span>

<span class="hljs-attr">services:</span>
  <span class="hljs-attr">mongodb:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">mongo</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">mongo</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"27017:27017"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">MONGO_INITDB_ROOT_USERNAME:</span> <span class="hljs-string">admin</span>
      <span class="hljs-attr">MONGO_INITDB_ROOT_PASSWORD:</span> <span class="hljs-string">password</span>

  <span class="hljs-attr">mongo-express:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">mongo-express</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">mongo-express</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"8081:8081"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">ME_CONFIG_MONGODB_ADMINUSERNAME:</span> <span class="hljs-string">admin</span>
      <span class="hljs-attr">ME_CONFIG_MONGODB_ADMINPASSWORD:</span> <span class="hljs-string">password</span>
      <span class="hljs-attr">ME_CONFIG_MONGODB_SERVER:</span> <span class="hljs-string">mongodb</span>
    <span class="hljs-attr">depends_on:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">mongodb</span>

  <span class="hljs-attr">todo-app:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">todo-app:1.0</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">todo-app</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"3000:3000"</span>
    <span class="hljs-attr">depends_on:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">mongodb</span>
</code></pre>
<p>The <code>todo-app</code> service includes:</p>
<ul>
<li><p><strong>image: todo-app:1.0</strong> that uses the Docker image we just rebuilt</p>
</li>
<li><p><strong>container_name: todo-app</strong> that gives the container a friendly name</p>
</li>
<li><p><strong>ports: "3000:3000"</strong> that exposes the app on port 3000</p>
</li>
<li><p><strong>depends_on: mongodb</strong> that ensures MongoDB starts before the app</p>
</li>
</ul>
<h3 id="heading-start-all-services">Start All Services</h3>
<p>First, stop any running containers:</p>
<pre><code class="lang-bash">docker compose down
</code></pre>
<p><strong>If you have port 3000 running in your local system, then stop it (that is, free up port 3000).</strong></p>
<p>We were running the server locally before, but now that we’ve built a Docker image, the app runs inside a container, so it’s no longer dependent on the local machine’s environment.</p>
<pre><code class="lang-yaml"><span class="hljs-string">node</span> <span class="hljs-string">server.js</span>
<span class="hljs-string">Server</span> <span class="hljs-string">→</span> <span class="hljs-string">http://localhost:3000</span>
</code></pre>
<p>Now stop it with Ctrl + C in that terminal. That’s it.</p>
<p>Then start everything together:</p>
<pre><code class="lang-bash">docker compose up -d
```

You should see:
```
[+] Running 4/4
 ✔ Network docker_tut_default  Created
 ✔ Container mongo             Started
 ✔ Container mongo-express     Started
 ✔ Container todo-app          Started
</code></pre>
<h3 id="heading-verify-everything-works">Verify Everything Works</h3>
<p>Check that all containers are running:</p>
<pre><code class="lang-bash">docker ps
```

Expected output:
```
CONTAINER ID   IMAGE           COMMAND                  CREATED          STATUS          PORTS                      NAMES
a1b2c3d4e5f6   todo-app:1.0    <span class="hljs-string">"node server.js"</span>         30 seconds ago   Up 28 seconds   0.0.0.0:3000-&gt;3000/tcp     todo-app
3d7c797fde1d   mongo-express   <span class="hljs-string">"/sbin/tini -- /dock…"</span>   30 seconds ago   Up 29 seconds   0.0.0.0:8081-&gt;8081/tcp     mongo-express
4511ade73c38   mongo           <span class="hljs-string">"docker-entrypoint.s…"</span>   30 seconds ago   Up 29 seconds   0.0.0.0:27017-&gt;27017/tcp   mongo
```

<span class="hljs-comment">## Test Your Application</span>

Now <span class="hljs-built_in">let</span><span class="hljs-string">'s verify everything works:

### 1. Access Your Todo App
Open your browser and go to:
```
http://localhost:3000
```

### 2. Create Some Todos
Add a few todo items to test the functionality. Try uploading images too!

### 3. Verify in Mongo Express
Open Mongo Express:
```
http://localhost:8081</span>
</code></pre>
<p>Navigate to the <code>todos</code> database, then the <code>todos</code> collection. You should see all the todos you just created with their complete data.</p>
<h3 id="heading-what-changed-and-why-it-works">What Changed and Why It Works</h3>
<p><strong>Before the fix:</strong></p>
<ul>
<li><p>Connection string used <code>localhost:27017</code> ❌</p>
</li>
<li><p>Container looked for MongoDB on itself</p>
</li>
<li><p>Connection failed with <code>ENOTFOUND</code> error</p>
</li>
</ul>
<p><strong>After the fix:</strong></p>
<ul>
<li><p>Connection string uses <code>mongodb:27017</code> ✅</p>
</li>
<li><p>Docker's internal DNS resolves <code>mongodb</code> to the MongoDB container</p>
</li>
<li><p>Connection succeeds and data flows properly</p>
</li>
</ul>
<p>This is a crucial lesson in Docker networking: containers communicate using service names, not <code>localhost</code>. Docker Compose automatically creates a network where all services can find each other by name.</p>
<h3 id="heading-how-to-manage-your-containers">How to Manage Your Containers</h3>
<p>Here’s a quick overview of how to manage your containers once you have them up and running. You’ll typically use these common commands:</p>
<p><strong>Stop all services:</strong></p>
<pre><code class="lang-bash">docker compose down
</code></pre>
<p><strong>View logs from your app:</strong></p>
<pre><code class="lang-bash">docker compose logs todo-app
</code></pre>
<p><strong>View logs in real-time:</strong></p>
<pre><code class="lang-bash">docker compose logs -f todo-app
</code></pre>
<p><strong>Rebuild after code changes:</strong></p>
<pre><code class="lang-bash">docker build -t todo-app:1.0 .
docker compose up -d --force-recreate todo-app
</code></pre>
<p>Your application is now fully containerized and production-ready. All three services work together seamlessly, and you can deploy this entire stack anywhere Docker is supported with just the <code>docker-compose.yml</code> file and your built image.</p>
<p>Get the full updated code <a target="_blank" href="https://github.com/Oghenekparobo/docker_tut_js/tree/docker-image">here</a>.</p>
<h2 id="heading-how-to-create-a-private-docker-repository">How to Create a Private Docker Repository</h2>
<p>Now we want to store our custom Docker image in a private container registry (instead of our local machine only). This gives you three major advantages:</p>
<ol>
<li><p><strong>Controlled access</strong> – Only people or servers you explicitly authorize can pull (or push) the image. Your code and dependencies stay private and secure.</p>
</li>
<li><p><strong>Reliable distribution</strong> – Anyone (or any server) with the correct AWS credentials can pull the exact same image from anywhere in the world, eliminating “it works on my machine” problems.</p>
</li>
<li><p><strong>Versioning and lifecycle management</strong> – You can keep multiple tagged versions (1.0, 2.0, latest, and so on) and easily roll back if needed.</p>
</li>
</ol>
<p>The first step is to create a private Docker repository, also known as a container registry. In this case, we will use <a target="_blank" href="https://aws.amazon.com/ecr/"><strong>AWS Elastic Container Registry (ECR)</strong></a>. Amazon ECR is a fully managed container registry that makes it easy to store, manage, share, and deploy your container images and artifacts securely from anywhere.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763044840247/bbfb5cfa-aa22-4a4f-a5e4-da91cd539063.png" alt="Amazon ECR Landing page" class="image--center mx-auto" width="2880" height="1696" loading="lazy"></p>
<p>Once you’re on the home page, just click on the <strong>Create</strong> button. Name the repository the same as your image, todo-app, and then click Create to finalize the setup.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763045060132/b2f8b287-026c-47f6-bd44-9fbad62da0f5.png" alt="creating our repository on AWS ECR" class="image--center mx-auto" width="2880" height="1696" loading="lazy"></p>
<p>Don’t worry about the extra options – this isn’t an AWS tutorial.</p>
<p><strong>Note:</strong> In AWS ECR, each image has its own repository, where we store the different tagged versions of that image.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763045250855/477e4566-8f47-41f8-93e6-4c3bcb28668f.png" alt="AWS ECR our todo-app empty repository" class="image--center mx-auto" width="2880" height="1524" loading="lazy"></p>
<p>Now, to push our image into the private repository, we need to do two things. First, we have to log in to the private repo. This is necessary because you’ll need authenticate yourself before AWS allows you to push anything. In other words, when you push your local image to the repo, you’re basically saying, <em>“Yes, I have access to this registry. Here are my credentials.”</em></p>
<p>In our case, since we’re using AWS ECR, we will authenticate through AWS instead of typing our username and password manually.</p>
<h3 id="heading-step-1-get-your-aws-access-keys">Step 1: Get Your AWS Access Keys</h3>
<p>To locate your access keys in the AWS console, follow these steps:</p>
<ol>
<li><p>Log in to the AWS Console at <a target="_blank" href="https://console.aws.amazon.com">https://console.aws.amazon.com</a></p>
</li>
<li><p>Click your account name (top right corner) and go to Security Credentials</p>
</li>
<li><p>Scroll down to "Access keys" section</p>
</li>
<li><p>If you don't have an access key:</p>
<ul>
<li><p>Click "Create access key"</p>
</li>
<li><p>Select "Command Line Interface (CLI)"</p>
</li>
<li><p>Check the confirmation box and click Next</p>
</li>
<li><p>Add a description (optional) and click "Create access key"</p>
</li>
</ul>
</li>
<li><p><strong>IMPORTANT</strong>: Copy both the <strong>access key ID</strong> (looks like: <code>AKIAIOSFODNN7EXAMPLE</code>) and the <strong>secret access key</strong> (looks like: <code>wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY</code>). <strong>Save these immediately.</strong> The secret key is only shown once. If you lose it, you'll need to create a new key pair.</p>
</li>
</ol>
<p>Alternatively, if someone else manages your AWS account, you’ll need to ask your AWS administrator for:</p>
<ul>
<li><p>An IAM user with ECR permissions</p>
</li>
<li><p>The Access Key ID and Secret Access Key for that user</p>
</li>
</ul>
<h3 id="heading-step-2-check-if-aws-cli-is-installed">Step 2: Check if AWS CLI is installed</h3>
<p>You can do this by running this:</p>
<pre><code class="lang-bash">aws --version
</code></pre>
<h3 id="heading-step-3-configure-aws-cli-with-your-credentials">Step 3: Configure AWS CLI with your credentials</h3>
<p>Here’s how you can do this:</p>
<pre><code class="lang-bash">aws configure
```

It will prompt you <span class="hljs-keyword">for</span> 4 things:
```
AWS Access Key ID [None]: &lt;paste your Access Key ID here&gt;
AWS Secret Access Key [None]: &lt;paste your Secret Access Key here&gt;
Default region name [None]: eu-north-1 or any region of your choice
Default output format [None]: json
</code></pre>
<p>Just paste your keys when prompted, type <code>eu-north-1</code> or any region of your choice for region, and <code>json</code> for format (or just press Enter for format).</p>
<h3 id="heading-step-4-test-your-aws-configuration">Step 4: Test your AWS configuration</h3>
<p>Now you’ll want to test your config to make sure everything is set up properly:</p>
<pre><code class="lang-bash">aws sts get-caller-identity
</code></pre>
<p>This should show your AWS account details if everything is configured correctly.</p>
<h3 id="heading-step-5-login-to-ecr-docker-registry">Step 5: Login to ECR (Docker Registry)</h3>
<p>Now, login to ECR:</p>
<pre><code class="lang-bash">aws ecr get-login-password --region eu-north-1 | docker login --username AWS --password-stdin 244836489456.dkr.ecr.eu-north-1.amazonaws.com
</code></pre>
<p>You should see: <strong>"Login Succeeded"</strong>.</p>
<h3 id="heading-understanding-image-naming-in-docker-repositories">Understanding Image Naming in Docker Repositories</h3>
<p>Every Docker image has a name that tells Docker where to find or store it. For example, when you run:</p>
<pre><code class="lang-yaml"><span class="hljs-string">docker</span> <span class="hljs-string">pull</span> <span class="hljs-string">mongo:4.2</span>
</code></pre>
<p>Docker is actually pulling from:</p>
<pre><code class="lang-yaml"><span class="hljs-string">docker.io/library/mongo:4.2</span>
</code></pre>
<p>Here’s what’s happening:</p>
<ul>
<li><p><a target="_blank" href="http://docker.io"><code>docker.io</code></a> is the registry (in this case, Docker Hub)</p>
</li>
<li><p><code>library</code> is the default namespace for official images</p>
</li>
<li><p><code>mongo</code> is the repository name</p>
</li>
<li><p><code>4.2</code> is the image tag</p>
</li>
</ul>
<p>If you build a local image like <code>todo-app:1.0</code>, that image exists only on your machine. Docker won’t know where to push it unless you include the full registry path.</p>
<p>For AWS ECR, the image name must include your ECR registry URL. For example:</p>
<pre><code class="lang-yaml"><span class="hljs-string">docker</span> <span class="hljs-string">tag</span> <span class="hljs-string">todo-app:1.0</span> <span class="hljs-number">244836489456.</span><span class="hljs-string">dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0</span>
</code></pre>
<p>Then you can push it with:</p>
<pre><code class="lang-yaml"><span class="hljs-string">docker</span> <span class="hljs-string">push</span> <span class="hljs-number">244836489456.</span><span class="hljs-string">dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0</span>
</code></pre>
<p>Without that full path, Docker won’t know <em>which</em> remote repository you’re referring to. That’s why just <code>todo-app:1.0</code> alone won’t work.</p>
<h3 id="heading-step-6-build-tag-and-push-your-image">Step 6: Build, Tag, and Push your image</h3>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763050402411/f4afd0ed-bc2a-4bf2-9bc4-4d373a54bab4.png" alt="aws push commands for the ecr todo-app repo " class="image--center mx-auto" width="2880" height="1524" loading="lazy"></p>
<pre><code class="lang-bash"><span class="hljs-comment"># Tag your local image with the full ECR path</span>
docker tag todo-app:1.0 244836489456.dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0

<span class="hljs-comment"># Now push it</span>
docker push 244836489456.dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0
</code></pre>
<p>⚠️ <strong>Note:</strong> Be careful when tagging and pushing your image, as every ECR repository URL is tied to a specific AWS account and region.</p>
<p>For example, in this tutorial, we’re using:</p>
<pre><code class="lang-yaml"><span class="hljs-number">244836489456.</span><span class="hljs-string">dkr.ecr.eu-north-1.amazonaws.com</span>
</code></pre>
<p>But your own ECR URL will be different depending on your AWS account and the region you selected (like <code>us-east-1</code>, <code>ap-south-1</code>, and so on).</p>
<p>So before you run your <code>docker tag</code> or <code>docker push</code> commands, make sure to replace the registry URL and region with your own.</p>
<p>If you don’t, Docker will throw errors like <em>“tag does not exist”</em> or <em>“repository not found.”</em></p>
<p>In short, stay calm, double-check your region, and always confirm the exact ECR URL shown in your AWS console before pushing.</p>
<p>If you successfully ran Step 6, you should see output similar to this in your terminal:</p>
<pre><code class="lang-yaml"><span class="hljs-string">stephenjohnson@Oghenekparobo</span> <span class="hljs-string">docker_tut</span> <span class="hljs-string">%</span> <span class="hljs-string">docker</span> <span class="hljs-string">tag</span> <span class="hljs-string">todo-app:1.0</span> <span class="hljs-number">244836489456.</span><span class="hljs-string">dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0</span>
<span class="hljs-string">stephenjohnson@Oghenekparobo</span> <span class="hljs-string">docker_tut</span> <span class="hljs-string">%</span> <span class="hljs-string">docker</span> <span class="hljs-string">push</span> <span class="hljs-number">244836489456.</span><span class="hljs-string">dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0</span>
<span class="hljs-string">The</span> <span class="hljs-string">push</span> <span class="hljs-string">refers</span> <span class="hljs-string">to</span> <span class="hljs-string">repository</span> [<span class="hljs-number">244836489456.</span><span class="hljs-string">dkr.ecr.eu-north-1.amazonaws.com/todo-app</span>]
<span class="hljs-attr">4f94b5cbe8ab:</span> <span class="hljs-string">Pushed</span> 
<span class="hljs-attr">85ba7bf54231:</span> <span class="hljs-string">Pushed</span> 
<span class="hljs-attr">4ea46a43fa07:</span> <span class="hljs-string">Pushed</span> 
<span class="hljs-attr">dee30873f229:</span> <span class="hljs-string">Pushed</span> 
<span class="hljs-attr">e78159dbd370:</span> <span class="hljs-string">Pushed</span> 
<span class="hljs-attr">a358a725b813:</span> <span class="hljs-string">Pushed</span> 
<span class="hljs-attr">cd8a6003174c:</span> <span class="hljs-string">Pushed</span> 
<span class="hljs-attr">abb63e49e652:</span> <span class="hljs-string">Pushed</span> 
<span class="hljs-attr">6cc65bdde70e:</span> <span class="hljs-string">Pushed</span> 
<span class="hljs-attr">41a4e3939504:</span> <span class="hljs-string">Pushed</span> 
<span class="hljs-attr">3520c50ae60e:</span> <span class="hljs-string">Pushed</span> 
<span class="hljs-attr">75ba6634710f:</span> <span class="hljs-string">Pushed</span> 
<span class="hljs-attr">1.0: digest: sha256:51f07267936fc94d9b677db8a760801e6c5fd4764f4bb2bd7b4dd150c756a39b size:</span> <span class="hljs-number">2842</span>
</code></pre>
<p>This confirms your image was successfully pushed to your private AWS ECR repository.</p>
<p>You can now go to the AWS Management Console and then ECR, and you should see your <code>todo-app</code> image listed there, along with the tag <code>1.0</code>.</p>
<p>At this point, your image is safely stored in AWS ECR and ready to be pulled or deployed anywhere that has access to your repository.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1763066904897/fe44a2e2-1e20-4ff5-88ec-799475b2fe0d.png" alt="your image now deployed on AWS ECR" class="image--center mx-auto" width="2880" height="1448" loading="lazy"></p>
<h2 id="heading-assignment-create-and-push-a-new-version-of-your-app"><strong>Assignment: Create and Push a New Version of Your App</strong></h2>
<p>Now that your first image (<code>todo-app:1.0</code>) has been successfully pushed to AWS ECR, it’s time to simulate a real-world workflow where developers make updates and release new versions of their applications.</p>
<p>Now, you’ll make a small change to your Node.js app, rebuild it, and push the updated version as <code>todo-app:2.0</code>.</p>
<h3 id="heading-deploying-our-image">Deploying Our Image</h3>
<p>Now it’s time to deploy our image using Docker Compose.</p>
<p>Up to this point, we have been running our app using a local image:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">image:</span> <span class="hljs-string">todo-app:1.0</span>
</code></pre>
<p>But now that your image lives inside AWS ECR, we need to replace that line with the full ECR image URI, because Docker must know exactly where to pull the image from.</p>
<p>Local image:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">image:</span> <span class="hljs-string">todo-app:1.0</span>
</code></pre>
<p>Private repository image (ECR):</p>
<pre><code class="lang-yaml"><span class="hljs-attr">image:</span> <span class="hljs-number">244836489456.</span><span class="hljs-string">dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0</span>
</code></pre>
<p>Docker cannot magically guess where “todo-app:1.0” is stored. If you don’t include the full registry URL, Docker will assume it’s looking at your <strong>local machine</strong>, not AWS.</p>
<p>Here is the clean, fixed, properly formatted docker-compose file that pulls your app from ECR:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">"3.8"</span>

<span class="hljs-attr">services:</span>
  <span class="hljs-attr">my-app:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-number">244836489456.</span><span class="hljs-string">dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">my-app</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"3000:3000"</span>
    <span class="hljs-attr">depends_on:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">mongodb</span>

  <span class="hljs-attr">mongodb:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">mongo</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">mongo</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"27017:27017"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">MONGO_INITDB_ROOT_USERNAME:</span> <span class="hljs-string">admin</span>
      <span class="hljs-attr">MONGO_INITDB_ROOT_PASSWORD:</span> <span class="hljs-string">password</span>

  <span class="hljs-attr">mongo-express:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">mongo-express</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">mongo-express</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"8081:8081"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">ME_CONFIG_MONGODB_ADMINUSERNAME:</span> <span class="hljs-string">admin</span>
      <span class="hljs-attr">ME_CONFIG_MONGODB_ADMINPASSWORD:</span> <span class="hljs-string">password</span>
      <span class="hljs-attr">ME_CONFIG_MONGODB_SERVER:</span> <span class="hljs-string">mongodb</span>
    <span class="hljs-attr">depends_on:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">mongodb</span>
</code></pre>
<p><strong>Why “my-app” instead of “todo-app”?</strong></p>
<p>In this case, we renamed it to avoid confusion between:</p>
<ul>
<li><p>our <strong>local</strong> “todo-app:1.0”</p>
</li>
<li><p>our <strong>ECR</strong> “todo-app:1.0”</p>
</li>
</ul>
<p>This keeps things clean, but you can rename it back if you want.</p>
<h3 id="heading-why-must-we-use-the-full-image-url-for-ecr">Why Must We Use the Full Image URL for ECR?</h3>
<p>Other containers like mongo and mongo-express work like this:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">image:</span> <span class="hljs-string">mongo</span>
<span class="hljs-attr">image:</span> <span class="hljs-string">mongo-express</span>
</code></pre>
<p>Because Docker knows these are on <strong>Docker Hub</strong>.</p>
<p>But for a private repo like AWS ECR, Docker has no idea where “todo-app” is unless you give the full path:</p>
<pre><code class="lang-yaml"><span class="hljs-string">AWS_ACCOUNT_ID.dkr.ecr.REGION.amazonaws.com/repository_name:tag</span>
</code></pre>
<p>This tells Docker:</p>
<ul>
<li><p>which account</p>
</li>
<li><p>which region</p>
</li>
<li><p>which repo</p>
</li>
<li><p>which version</p>
</li>
</ul>
<p>Without this URL, Docker can’t pull the image.</p>
<p>Every time we want to <em>pull</em> from a private ECR repo, including using Docker Compose, we must be logged in.</p>
<p>Run this:</p>
<pre><code class="lang-yaml"><span class="hljs-string">aws</span> <span class="hljs-string">ecr</span> <span class="hljs-string">get-login-password</span> <span class="hljs-string">--region</span> <span class="hljs-string">eu-north-1</span> <span class="hljs-string">|</span> <span class="hljs-string">docker</span> <span class="hljs-string">login</span> <span class="hljs-string">--username</span> <span class="hljs-string">AWS</span> <span class="hljs-string">--password-stdin</span> <span class="hljs-number">244836489456.</span><span class="hljs-string">dkr.ecr.eu-north-1.amazonaws.com</span>
</code></pre>
<p>If you’re not logged in, Docker Compose will throw:</p>
<p>❌ <code>pull access denied</code><br>❌ <code>repository does not exist</code><br>❌ <code>no basic auth credentials</code></p>
<h3 id="heading-deploy-your-app-using-docker-compose">Deploy Your App Using Docker Compose</h3>
<p>Before deploying, it’s best practice to stop and remove any existing containers to avoid port conflicts or orphaned containers:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># Stop all running containers in this project</span>
<span class="hljs-string">docker-compose</span> <span class="hljs-string">down</span> <span class="hljs-string">--remove-orphans</span>

<span class="hljs-comment"># Optional: verify nothing is running</span>
<span class="hljs-string">docker</span> <span class="hljs-string">ps</span>
</code></pre>
<p>This ensures that port 3000 and other mapped ports are free, preventing errors when starting new containers.</p>
<p>Once the environment is clean, deploy your stack:</p>
<pre><code class="lang-yaml"><span class="hljs-string">docker-compose</span> <span class="hljs-string">up</span> <span class="hljs-string">-d</span>
</code></pre>
<p>Docker Compose will:</p>
<ol>
<li><p><strong>Connect to AWS ECR</strong> – Authenticate and pull the <code>todo-app:1.0</code> image from your private repository.</p>
</li>
<li><p><strong>Start MongoDB</strong> – Launch the database container with your configured credentials.</p>
</li>
<li><p><strong>Start Mongo Express</strong> – Launch the web-based MongoDB admin interface.</p>
</li>
<li><p><strong>Start your Node.js app</strong> – Launch the <code>my-app</code> container, linked to MongoDB.</p>
</li>
</ol>
<p>Check the running containers:</p>
<pre><code class="lang-yaml"><span class="hljs-string">docker</span> <span class="hljs-string">ps</span>
</code></pre>
<p>You should see:</p>
<ul>
<li><p><code>mongo</code></p>
</li>
<li><p><code>mongo-express</code></p>
</li>
<li><p><code>my-app</code></p>
</li>
</ul>
<p>If <code>my-app</code> fails to start, it’s usually because <strong>port 3000 is already in use</strong>. Ensure it’s free by stopping any process using it:</p>
<pre><code class="lang-yaml"><span class="hljs-string">lsof</span> <span class="hljs-string">-i</span> <span class="hljs-string">:3000</span>
<span class="hljs-string">kill</span> <span class="hljs-number">-9</span> <span class="hljs-string">&lt;PID&gt;</span>  <span class="hljs-comment"># if a process is using it</span>
</code></pre>
<p>Then rerun:</p>
<pre><code class="lang-yaml"><span class="hljs-string">docker-compose</span> <span class="hljs-string">up</span> <span class="hljs-string">-d</span>
</code></pre>
<p>To access your app:</p>
<ul>
<li><p>Node.js app: <a target="_blank" href="http://localhost:3000"><code>http://localhost:3000</code></a></p>
</li>
<li><p>Mongo Express: <a target="_blank" href="http://localhost:8081"><code>http://localhost:8081</code></a></p>
</li>
</ul>
<p>This workflow ensures a clean start and avoids common port or container conflicts.</p>
<h3 id="heading-sharing-our-private-docker-image">Sharing our Private Docker Image</h3>
<p>Once your Node.js app is pushed to AWS ECR, it’s safely stored in your private repository. But what if another developer, team member, or server needs to run that same image? Since it’s private, Docker cannot pull it automatically like public images (e.g., <code>mongo</code> or <code>nginx</code>). They need <strong>authenticated access</strong>.</p>
<p>Here’s how they can get and use your image:</p>
<h4 id="heading-1-grant-iam-access">1. Grant IAM Access</h4>
<p>Your collaborator needs an <strong>AWS IAM user or role</strong> with permissions for ECR. At minimum, the policy should allow:</p>
<ul>
<li><p><code>ecr:GetAuthorizationToken</code></p>
</li>
<li><p><code>ecr:BatchCheckLayerAvailability</code></p>
</li>
<li><p><code>ecr:GetDownloadUrlForLayer</code></p>
</li>
<li><p><code>ecr:BatchGetImage</code></p>
</li>
</ul>
<p>You can create a dedicated IAM user for this and provide them an Access Key ID and a Secret Access Key.</p>
<h4 id="heading-2-install-and-configure-aws-cli">2. Install and Configure AWS CLI</h4>
<p>The collaborator must have the AWS CLI installed. Then they configure it with their credentials:</p>
<pre><code class="lang-yaml"><span class="hljs-string">aws</span> <span class="hljs-string">configure</span>
</code></pre>
<p>They enter:</p>
<ul>
<li><p>Access Key ID</p>
</li>
<li><p>Secret Access Key</p>
</li>
<li><p>Default region (the same region where the ECR repo exists, for example, <code>eu-north-1</code>)</p>
</li>
<li><p>Default output format (usually <code>json</code>)</p>
</li>
</ul>
<h4 id="heading-3-authenticate-docker-with-ecr">3. Authenticate Docker with ECR</h4>
<p>Before pulling the image, Docker must authenticate using the AWS credentials:</p>
<pre><code class="lang-yaml"><span class="hljs-string">aws</span> <span class="hljs-string">ecr</span> <span class="hljs-string">get-login-password</span> <span class="hljs-string">--region</span> <span class="hljs-string">eu-north-1</span> <span class="hljs-string">|</span> <span class="hljs-string">docker</span> <span class="hljs-string">login</span> <span class="hljs-string">--username</span> <span class="hljs-string">AWS</span> <span class="hljs-string">--password-stdin</span> <span class="hljs-number">244836489456.</span><span class="hljs-string">dkr.ecr.eu-north-1.amazonaws.com</span>
</code></pre>
<p>If successful, Docker will respond with:</p>
<pre><code class="lang-yaml"><span class="hljs-string">Login</span> <span class="hljs-string">Succeeded</span>
</code></pre>
<h4 id="heading-4-pull-the-image">4. Pull the Image</h4>
<p>Now the collaborator can pull the image using the full ECR URI, which includes your AWS account, region, repository name, and tag:</p>
<pre><code class="lang-yaml"><span class="hljs-string">docker</span> <span class="hljs-string">pull</span> <span class="hljs-number">244836489456.</span><span class="hljs-string">dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0</span>
</code></pre>
<h4 id="heading-5-run-the-container">5. Run the Container</h4>
<p>After pulling, they can run the container locally:</p>
<pre><code class="lang-yaml"><span class="hljs-string">docker</span> <span class="hljs-string">run</span> <span class="hljs-string">-p</span> <span class="hljs-number">3000</span><span class="hljs-string">:3000</span> <span class="hljs-number">244836489456.</span><span class="hljs-string">dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0</span>
</code></pre>
<p>Or include it in a Docker Compose file, replacing the <code>image:</code> field with the full ECR URI.</p>
<ul>
<li><p>Public images like <code>mongo</code> don’t require this because Docker Hub is open. Private ECR images require explicit authentication.</p>
</li>
<li><p>Every pull from a private repository requires an active login**.** Docker cannot guess credentials.</p>
</li>
<li><p>Using the full image URI ensures Docker knows exactly where to fetch the image.</p>
</li>
</ul>
<p>This setup allows your team to share, deploy, or run your application anywhere, on local machines, staging servers, or production, while keeping your repository private and secure.</p>
<h2 id="heading-docker-volumes">Docker Volumes</h2>
<p>When running containers like MongoDB, all data created inside a container is ephemeral. If the container stops or is removed, all data inside it disappears. This is fine for testing, but not suitable for production.</p>
<p>To solve this, Docker provides <strong>volumes</strong>, which allow containers to store data outside the container, either on the host machine or in Docker-managed storage, so it survives container restarts, rebuilds, or removals.</p>
<h3 id="heading-how-docker-volumes-work">How Docker Volumes Work</h3>
<p>Think of Docker volumes as persistent folders for containers:</p>
<ul>
<li><p>Data written inside a volume remains safe, even if the container is removed.</p>
</li>
<li><p>Containers can read/write to these volumes.</p>
</li>
<li><p>Volumes are essential for databases, logs, file uploads, or any persistent data your application needs.</p>
</li>
</ul>
<h3 id="heading-types-of-docker-volumes">Types of Docker Volumes</h3>
<p>Docker has three main types of volumes:</p>
<h4 id="heading-1-named-volumes">1. Named Volumes</h4>
<p>Named volumes are user-defined volumes with a clear name, that are fully managed by Docker. You’d typically use them in production databases and for persistent data that containers can share.</p>
<p>Here’s an example:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">volumes:</span>
  <span class="hljs-attr">mongo-data:</span>
</code></pre>
<p>And in a service:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">volumes:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-string">mongo-data:/data/db</span>
</code></pre>
<h4 id="heading-2-bind-mounts">2. Bind Mounts</h4>
<p>Blind mounts map a folder from your <strong>host machine</strong> into the container. They’re often used for development, live syncing files, logs, and uploaded files.</p>
<p>Here’s an example:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">volumes:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-string">./uploads:/usr/src/app/uploads</span>
</code></pre>
<h4 id="heading-3-anonymous-volumes">3. Anonymous Volumes</h4>
<p>These are volumes without a name. Docker just assigns them a random name. You’d use them for temporary data for testing (and they’re not commonly used in production).</p>
<p>Here’s an example:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">volumes:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-string">/data/tmp</span>
</code></pre>
<h3 id="heading-example-docker-compose-file-using-volumes">Example Docker Compose File Using Volumes</h3>
<p>Here’s a full <code>docker-compose.yml</code> file using the most common volume types for a Node.js + MongoDB + Mongo Express stack:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">"3.8"</span>

<span class="hljs-attr">services:</span>
  <span class="hljs-attr">my-app:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-number">244836489456.</span><span class="hljs-string">dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">my-app</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"3000:3000"</span>
    <span class="hljs-attr">depends_on:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">mongodb</span>
    <span class="hljs-attr">volumes:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">./uploads:/usr/src/app/uploads</span>  <span class="hljs-comment"># bind mount for file uploads</span>

  <span class="hljs-attr">mongodb:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">mongo</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">mongo</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"27017:27017"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">MONGO_INITDB_ROOT_USERNAME:</span> <span class="hljs-string">admin</span>
      <span class="hljs-attr">MONGO_INITDB_ROOT_PASSWORD:</span> <span class="hljs-string">password</span>
    <span class="hljs-attr">volumes:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">mongo-data:/data/db</span>  <span class="hljs-comment"># named volume for persistent database storage</span>

  <span class="hljs-attr">mongo-express:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">mongo-express</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">mongo-express</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"8081:8081"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">ME_CONFIG_MONGODB_ADMINUSERNAME:</span> <span class="hljs-string">admin</span>
      <span class="hljs-attr">ME_CONFIG_MONGODB_ADMINPASSWORD:</span> <span class="hljs-string">password</span>
      <span class="hljs-attr">ME_CONFIG_MONGODB_SERVER:</span> <span class="hljs-string">mongodb</span>
    <span class="hljs-attr">depends_on:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">mongodb</span>

<span class="hljs-attr">volumes:</span>
  <span class="hljs-attr">mongo-data:</span>  <span class="hljs-comment"># named volume definition</span>
</code></pre>
<p>How this code is working:</p>
<ol>
<li><p><strong>MongoDB Volume</strong> (<code>mongo-data</code>): This is a named volume. It stores all database files under <code>/data/db</code> inside the container. It survives container restarts, removals, or rebuilds.</p>
</li>
<li><p><strong>Node.js Uploads (</strong><code>./uploads</code>): This is a <strong>bind mount</strong>. It maps the <code>uploads</code> folder on your host to <code>/usr/src/app/uploads</code> inside the container. Any uploaded files are immediately visible on your host.</p>
</li>
<li><p><strong>Anonymous Volume</strong>: These are not shown in this file because it’s rarely used in production. Temporary data storage is created automatically by Docker if a volume is defined without a name.</p>
</li>
</ol>
<h4 id="heading-visual-concept-simplified">Visual Concept (Simplified):</h4>
<pre><code class="lang-yaml"><span class="hljs-string">Host</span> <span class="hljs-string">Machine</span>
<span class="hljs-string">├─</span> <span class="hljs-string">/project/uploads</span>  <span class="hljs-string">←</span> <span class="hljs-string">bind</span> <span class="hljs-string">mount,</span> <span class="hljs-string">synced</span> <span class="hljs-string">with</span> <span class="hljs-string">container</span>
<span class="hljs-string">├─</span> <span class="hljs-string">Docker</span> <span class="hljs-string">Volumes</span>
<span class="hljs-string">│</span>  <span class="hljs-string">└─</span> <span class="hljs-string">mongo-data</span>    <span class="hljs-string">←</span> <span class="hljs-string">named</span> <span class="hljs-string">volume,</span> <span class="hljs-string">persistent</span> <span class="hljs-string">MongoDB</span> <span class="hljs-string">data</span>

<span class="hljs-string">Containers</span>
<span class="hljs-string">├─</span> <span class="hljs-string">my-app</span>
<span class="hljs-string">│</span>  <span class="hljs-string">└─</span> <span class="hljs-string">/usr/src/app/uploads</span>  <span class="hljs-string">←</span> <span class="hljs-string">sees</span> <span class="hljs-string">host</span> <span class="hljs-string">uploads</span> <span class="hljs-string">folder</span>
<span class="hljs-string">├─</span> <span class="hljs-string">mongodb</span>
<span class="hljs-string">│</span>  <span class="hljs-string">└─</span> <span class="hljs-string">/data/db</span>             <span class="hljs-string">←</span> <span class="hljs-string">uses</span> <span class="hljs-string">named</span> <span class="hljs-string">volume</span> <span class="hljs-string">mongo-data</span>
<span class="hljs-string">├─</span> <span class="hljs-string">mongo-express</span>
</code></pre>
<h3 id="heading-takeaways">Takeaways</h3>
<ul>
<li><p>Always use volumes for data you care about.</p>
</li>
<li><p>Named volumes are best for databases in production.</p>
</li>
<li><p>Bind mounts are best for development and live syncing.</p>
</li>
<li><p>Anonymous volumes are rarely needed outside testing.</p>
</li>
<li><p>Volumes separate container lifecycle from data lifecycle, which is a cornerstone of Docker best practices.</p>
</li>
</ul>
<h3 id="heading-start-your-application">Start Your Application</h3>
<p>Once your Docker Compose is configured with volumes, the next step is to start your application and make sure the volumes are working correctly. Here’s a simple step-by-step guide.</p>
<p><strong>1. Start the Containers</strong></p>
<p>Run:</p>
<pre><code class="lang-yaml"><span class="hljs-string">docker-compose</span> <span class="hljs-string">up</span> <span class="hljs-string">-d</span>
</code></pre>
<p>The <code>-d</code> flag runs the containers in detached mode (in the background).</p>
<p>Docker will:</p>
<ul>
<li><p>Pull your app image from AWS ECR (if you’re logged in)</p>
</li>
<li><p>Start MongoDB with the named volume</p>
</li>
<li><p>Start Mongo Express</p>
</li>
<li><p>Start your Node.js app</p>
</li>
</ul>
<p><strong>2. Check Running Containers</strong></p>
<p>To see if everything started correctly:</p>
<pre><code class="lang-yaml"><span class="hljs-string">docker</span> <span class="hljs-string">ps</span>
</code></pre>
<p>You should see something like:</p>
<pre><code class="lang-yaml"><span class="hljs-string">CONTAINER</span> <span class="hljs-string">ID</span>   <span class="hljs-string">IMAGE</span>                                               <span class="hljs-string">STATUS</span>          <span class="hljs-string">PORTS</span>
<span class="hljs-string">2a2e120cc912</span>   <span class="hljs-number">244836489456.</span><span class="hljs-string">dkr.ecr.eu-north-1.amazonaws.com/todo-app:1.0</span>   <span class="hljs-string">Up</span> <span class="hljs-string">5s</span>    <span class="hljs-number">0.0</span><span class="hljs-number">.0</span><span class="hljs-number">.0</span><span class="hljs-string">:3000-&gt;3000/tcp</span>
<span class="hljs-string">f4d5a1ab1234</span>   <span class="hljs-string">mongo</span>                                               <span class="hljs-string">Up</span> <span class="hljs-string">5s</span>          <span class="hljs-number">0.0</span><span class="hljs-number">.0</span><span class="hljs-number">.0</span><span class="hljs-string">:27017-&gt;27017/tcp</span>
<span class="hljs-string">c3d5b2bc2345</span>   <span class="hljs-string">mongo-express</span>                                      <span class="hljs-string">Up</span> <span class="hljs-string">5s</span>          <span class="hljs-number">0.0</span><span class="hljs-number">.0</span><span class="hljs-number">.0</span><span class="hljs-string">:8081-&gt;8081/tcp</span>
</code></pre>
<p><strong>3. Verify Volumes</strong></p>
<p>List Docker volumes:</p>
<pre><code class="lang-yaml"><span class="hljs-string">docker</span> <span class="hljs-string">volume</span> <span class="hljs-string">ls</span>
</code></pre>
<p>You should see your named volume, for example <code>mongo-data</code>.</p>
<p>Inspect the volume:</p>
<pre><code class="lang-yaml"><span class="hljs-string">docker</span> <span class="hljs-string">volume</span> <span class="hljs-string">inspect</span> <span class="hljs-string">docker_tut_mongo-data</span>
</code></pre>
<p>This will show where Docker stores your MongoDB data on the host, for example:</p>
<pre><code class="lang-yaml">[
    {
        <span class="hljs-attr">"Name":</span> <span class="hljs-string">"mongo-data"</span>,
        <span class="hljs-attr">"Driver":</span> <span class="hljs-string">"local"</span>,
        <span class="hljs-attr">"Mountpoint":</span> <span class="hljs-string">"/var/lib/docker/volumes/mongo-data/_data"</span>,
        <span class="hljs-attr">"Labels":</span> {},
        <span class="hljs-attr">"Scope":</span> <span class="hljs-string">"local"</span>
    }
]
</code></pre>
<p><strong>Anything stored in</strong> <code>/data/db</code> <strong>inside MongoDB is actually saved here on your host.</strong></p>
<p><strong>4. Test Data Persistence</strong></p>
<ol>
<li><p>Connect to MongoDB or your app and add some data.</p>
</li>
<li><p>Stop and remove the container:</p>
</li>
</ol>
<pre><code class="lang-yaml"><span class="hljs-string">docker-compose</span> <span class="hljs-string">down</span>
</code></pre>
<ol start="3">
<li>Restart the app:</li>
</ol>
<pre><code class="lang-yaml"><span class="hljs-string">docker-compose</span> <span class="hljs-string">up</span> <span class="hljs-string">-d</span>
</code></pre>
<ol start="4">
<li>Check your data again.</li>
</ol>
<ul>
<li><p>Because MongoDB uses the named volume, your data is still there.</p>
</li>
<li><p>This proves the volume is persistent.</p>
</li>
</ul>
<p>5. Optional: Check Node.js Uploads (Bind Mount)</p>
<ul>
<li><p>If you uploaded a file through your app, check your project folder <code>./uploads</code>.</p>
</li>
<li><p>You should see the file appear on your host machine because bind mounts sync host and container directories.</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Well done, you have made it to the end of this comprehensive Docker tutorial. From unraveling the basics of containers and images, to networking, Docker Compose, volumes, and even deploying to a private AWS ECR repository, you've built a fully containerized Node.js application stack that's production-ready and scalable. These are hands-on skills that will transform how you develop, collaborate, and deploy applications in real-world scenarios.</p>
<p>Thank you for sticking with it. Docker can feel overwhelming at first – those long commands, networking quirks, and persistent data challenges aren't trivial. But getting to this point? It means you've conquered a steep learning curve and reached new heights in your development journey. You're now equipped to eliminate "it works on my machine" headaches, streamline CI/CD pipelines, and level up as a backend or full-stack pro.</p>
<p>Keep experimenting: Tweak your todo-app, try multi-stage builds in your Dockerfile, or explore orchestration tools like Kubernetes next. The Docker ecosystem is vast, but with this foundation, you're ready to dive deeper. If you hit snags or have questions, the community on Docker Hub, Stack Overflow, or GitHub.</p>
<p>You can find the final code here: <a target="_blank" href="https://github.com/Oghenekparobo/docker_tut_js/tree/final">https://github.com/Oghenekparobo/docker_tut_js/tree/final</a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use SonarQube to Improve Your Code Quality ]]>
                </title>
                <description>
                    <![CDATA[ SonarQube is a powerful open-source tool that helps you maintain code quality and security by analyzing your codebase for bugs and vulnerabilities. And it can play a major role when integrated into your CI/CD pipeline. In this tutorial, we will cover... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-improve-your-code-quality-with-sonarqube/</link>
                <guid isPermaLink="false">6815023dec2e34790eeb71a8</guid>
                
                    <category>
                        <![CDATA[ sonarqube ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Code Quality ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Divya Valsala Saratchandran ]]>
                </dc:creator>
                <pubDate>Fri, 02 May 2025 17:34:53 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1746207275407/7b0da6c9-9bd7-40ca-853e-b1f7957acf3b.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>SonarQube is a powerful open-source tool that helps you maintain code quality and security by analyzing your codebase for bugs and vulnerabilities. And it can play a major role when integrated into your CI/CD pipeline.</p>
<p>In this tutorial, we will cover:</p>
<ol>
<li><p>What is SonarQube?</p>
</li>
<li><p>How SonarQube Improves Code Quality</p>
</li>
<li><p>Step-by-step Installation and Configuration</p>
</li>
<li><p>How to Run Your First Code Analysis</p>
</li>
</ol>
<h2 id="heading-what-is-sonarqube">What is SonarQube?</h2>
<p>SonarQube is an open-source tool that checks for code quality continuously. It analyzes code to find issues like duplication, bad practices, test coverage gaps, bugs, and vulnerabilities, giving detailed reports. It works with many programming languages like Java, C#, JavaScript, Python, TypeScript, and Kotlin.</p>
<p>You can add SonarQube to your CI/CD pipelines, IDEs, and version control systems like GitHub, GitLab, or Bitbucket. It provides detailed dashboards that show metrics, trends, and issues in your code.</p>
<p>You can use custom rules to enforce coding standards and reduce technical debt. SonarQube also supports code coverage analysis to help teams improve their tests. With the Quality Gate feature, teams can ensure only clean, maintainable code goes into production.</p>
<p>SonarQube offers both free and paid versions to suit any team size. Overall, it helps improve software quality and encourages good coding practices.</p>
<h2 id="heading-how-does-sonarqube-improve-code-quality">How Does SonarQube Improve Code Quality?</h2>
<p>Here’s how SonarQube helps improve code quality:</p>
<ol>
<li><p><strong>Early bug detection:</strong> Identifies bugs before they reach production</p>
</li>
<li><p><strong>Improved maintainability:</strong> Highlights code and design issues</p>
</li>
<li><p><strong>Security insights:</strong> Identifies vulnerabilities and security risks</p>
</li>
<li><p><strong>Code coverage:</strong> Integration with testing tools to monitor unit test coverage</p>
</li>
<li><p><strong>Customizable rules:</strong> Allows teams to set coding standards and policies</p>
</li>
<li><p><strong>Team collaboration:</strong> Ensures consistent code quality across development teams</p>
</li>
</ol>
<h2 id="heading-step-by-step-installation-and-configuration">Step-by-Step Installation and Configuration</h2>
<h3 id="heading-prerequisites"><strong>Prerequisites:</strong></h3>
<p>Here are the prerequisites that you will need before installing SonarQube</p>
<ol>
<li><p><strong>Java Runtime Environment(JRE)</strong>: Java 11 or above installed in your system.</p>
</li>
<li><p><strong>System Requirements</strong>: 2GB RAM minimum (Recommended: 4GB+).</p>
</li>
<li><p><strong>MacOS</strong>: You can use HomeBrew, which is the package manager for MacOS that simplifies the installation of software.</p>
</li>
</ol>
<p>Below are the steps to install SonarQube in your local machine:</p>
<h3 id="heading-download-sonarqube"><strong>Download SonarQube</strong></h3>
<p>Download the software from <a target="_blank" href="https://www.sonarsource.com/products/sonarqube/downloads/">sonarsource downloads</a> and choose the <em>Community Edition</em> for open-source projects.</p>
<h3 id="heading-extract-and-configure"><strong>Extract and Configure</strong></h3>
<p>To install SonarQube, you need to run the below command to unzip the file:</p>
<pre><code class="lang-bash">unzip sonarqube-&lt;version&gt;.zip
<span class="hljs-built_in">cd</span> sonarqube-&lt;version&gt;/bin/&lt;your-OS-folder&gt;
</code></pre>
<h3 id="heading-start-sonarqube"><strong>Start SonarQube</strong></h3>
<p>On Linux/Mac, you need to run the below command:</p>
<pre><code class="lang-bash">./sonar.sh start
</code></pre>
<p>On Windows, you need to run this one:</p>
<pre><code class="lang-plaintext">StartSonar.bat
</code></pre>
<h3 id="heading-access-sonarqube"><strong>Access SonarQube</strong></h3>
<p>To access SonarQube, you need to open browser and go to: <a target="_blank" href="http://localhost:9000">http://localhost:9000</a></p>
<p>Enter the default credentials:</p>
<ul>
<li><p><strong>Username:</strong> <code>admin</code></p>
</li>
<li><p><strong>Password:</strong> <code>admin</code> (you’ll be prompted to change it)</p>
</li>
</ul>
<p>The page will look similar to below:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1746152681985/0b1829cb-bd2a-4961-bc69-18f5d677d9dd.png" alt="SonarQube project creation page" class="image--center mx-auto" width="1758" height="1048" loading="lazy"></p>
<h3 id="heading-set-up-sonarqube-in-your-project">Set Up SonarQube in Your Project</h3>
<p>To set up SonarQube in your project, start by opening the Java project on your machine. In the project root, create a <strong>sonar-project.properties</strong> file.</p>
<p>Add the below key value pairs in the file:</p>
<pre><code class="lang-bash">sonar.projectKey=spring-myproject
sonar.projectName=My Project
sonar.projectVersion=1.0
sonar.sources=.
sonar.host.url=http://localhost:9000
</code></pre>
<h2 id="heading-how-to-run-your-first-code-analysis">How to Run Your First Code Analysis</h2>
<h3 id="heading-configure-and-run-sonarscanner">Configure and Run SonarScanner</h3>
<p>SonarScanner is the tool that actually sends your code to SonarQube for analysis. Below are the detailed steps to follow to use it:</p>
<h4 id="heading-install-sonarscanner">Install SonarScanner:</h4>
<p>On Windows/Linux, download the software from <a target="_blank" href="https://docs.sonarsource.com/sonarqube-server/10.4/analyzing-source-code/scanners/sonarscanner/">SonarSource</a> and unzip it:</p>
<pre><code class="lang-bash">unzip sonar-scanner-cli-&lt;version&gt;.zip
</code></pre>
<p>On MacOS, run the below command:</p>
<pre><code class="lang-plaintext">&gt;brew install sonar-scanner
</code></pre>
<p>For both Windows/Linux and MacOS, verify the install by running the below command:</p>
<pre><code class="lang-plaintext">&gt;sonar-scanner -v
</code></pre>
<h4 id="heading-configure-sonarscanner">Configure SonarScanner</h4>
<p>After installing SonarScanner, you’ll need to configure it by setting the <strong>SonarQube server</strong> URL and <strong>authentication token</strong>. Then go to your SonarQube profile (top-right corner &gt; My Account &gt; Security) and generate a token.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1746154994148/02ccf0cd-68ce-4447-bb1f-12ff04cd9e59.png" alt="Generate tokens in SolarQube" class="image--center mx-auto" width="1066" height="568" loading="lazy"></p>
<p>Provide a name for the token and click ‘Generate’:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1746155102747/834dcbac-070e-4958-9cb7-44a738059343.png" alt="Name token and then generate" class="image--center mx-auto" width="936" height="446" loading="lazy"></p>
<p>In the <code>sonar-project.properties</code> file in your project, add ‘sonar.login’ property and save.</p>
<pre><code class="lang-plaintext">sonar.projectKey=test-project
sonar.projectName=Test Project
sonar.host.url=http://localhost:9000
sonar.login=&lt;YOUR_TOKEN_HERE&gt;
</code></pre>
<h4 id="heading-run-the-analysis">Run the Analysis</h4>
<p>Once the SonarScanner is configured, you can start scanning your project.</p>
<p>In a terminal or command prompt, go to the root of your project (where sonar-project.properties is located).</p>
<p>Run the following command:</p>
<pre><code class="lang-plaintext">&gt;sonar-scanner
</code></pre>
<p>SonarScanner will analyze your code and push the results to your local SonarQube server. Visit <code>http://localhost:9000</code>, and you’ll see your project listed on the dashboard.</p>
<ul>
<li><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1746151289131/d2794dc7-1a53-4787-8137-668849d50d2b.png" alt="Scanner results dashboard" class="image--center mx-auto" width="3010" height="1674" loading="lazy"></li>
</ul>
<p>To view the analysis report, go to <a target="_blank" href="http://localhost:9000/dashboard?id=java-sonar-demo">http://localhost:9000/dashboard?id=java-sonar-demo</a>:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1746151477685/931d1170-3c90-45d2-ab07-b60b551f3856.png" alt="Analysis results" class="image--center mx-auto" width="3002" height="1786" loading="lazy"></p>
<p>If you go to the ‘Issues’ tab at top left corner, you can view different categories of Software Quality, Severity of the Issues, and various other attributes in your code.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1746151632987/090c61d0-0a37-4bb1-82a1-76f149a4cc86.png" alt="Detailed results" class="image--center mx-auto" width="2978" height="1674" loading="lazy"></p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Now you have installed and configured SonarQube and learned how to scan your code using SonarScanner. You can easily configure it in your projects for continuous code quality analysis.</p>
<p>This is a fantastic tool for keeping your code base clean and maintainable. As the next steps, you can consider adding test coverage reports, enforcing quality gates in your pipeline, and exploring SonarCloud for cloud-based analysis.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Create a Basic CI/CD Pipeline with Webhooks on Linux ]]>
                </title>
                <description>
                    <![CDATA[ In the fast-paced world of software development, delivering high-quality applications quickly and reliably is crucial. This is where CI/CD (Continuous Integration and Continuous Delivery/Deployment) comes into play. CI/CD is a set of practices and to... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/create-a-basic-cicd-pipeline-with-webhooks-on-linux/</link>
                <guid isPermaLink="false">67995e567a54c877fce42276</guid>
                
                    <category>
                        <![CDATA[ Linux ]]>
                    </category>
                
                    <category>
                        <![CDATA[ linux for beginners ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python 3 ]]>
                    </category>
                
                    <category>
                        <![CDATA[ python beginner ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ CI/CD ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Juan P. Romano ]]>
                </dc:creator>
                <pubDate>Tue, 28 Jan 2025 22:46:46 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1737640144719/9035597c-0a69-4146-93cc-8bd659384169.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In the fast-paced world of software development, delivering high-quality applications quickly and reliably is crucial. This is where <strong>CI/CD</strong> (Continuous Integration and Continuous Delivery/Deployment) comes into play.</p>
<p>CI/CD is a set of practices and tools designed to automate and streamline the process of integrating code changes, testing them, and deploying them to production. By adopting CI/CD, your team can reduce manual errors, speed up release cycles, and ensure that your code is always in a deployable state.</p>
<p>In this tutorial, we’ll focus on a beginner-friendly approach to setting up a basic CI/CD pipeline using Bitbucket, a Linux server, and Python with Flask. Specifically, we’ll create an automated process that pulls the latest changes from a Bitbucket repository to your Linux server whenever there’s a push or merge to a specific branch.</p>
<p>This process will be powered by Bitbucket webhooks and a simple Flask-based Python server that listens for incoming webhook events and triggers the deployment.</p>
<p>It’s important to note that CI/CD is a vast and complex field, and this tutorial is designed to provide a foundational understanding rather than to be an exhaustive guide.</p>
<p>We’ll cover the basics of setting up a CI/CD pipeline using tools that are accessible to beginners. Just keep in mind that real-world CI/CD systems often involve more advanced tools and configurations, such as containerization, orchestration, and multi-stage testing environments.</p>
<p>By the end of this tutorial, you’ll have a working example of how to automate deployments using Bitbucket, Linux, and Python, which you can build upon as you grow more comfortable with CI/CD concepts.</p>
<h3 id="heading-table-of-contents">Table of Contents:</h3>
<ol>
<li><p><a class="post-section-overview" href="#heading-why-is-cicd-important">Why is CI/CD Important?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-1-set-up-a-webhook-in-bitbucket">Step 1: Set Up a Webhook in Bitbucket</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-2-set-up-the-flask-listener-on-your-linux-server">Step 2: Set Up the Flask Listener on Your Linux Server</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-3-expose-the-flask-app-optional">Step 3: Expose the Flask App (Optional)</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-4-test-the-setup">Step 4: Test the Setup</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-5-security-considerations">Step 5: Security Considerations</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-why-is-cicd-important">Why is CI/CD Important?</h2>
<p>CI/CD has become a cornerstone of modern software development for several reasons. First and foremost, it accelerates the development process. By automating repetitive tasks like testing and deployment, developers can focus more on writing code and less on manual processes. This leads to faster delivery of new features and bug fixes, which is especially important in competitive markets where speed can be a differentiator.</p>
<p>Another key benefit of CI/CD is reduced errors and improved reliability. Automated testing ensures that every code change is rigorously checked for issues before it’s integrated into the main codebase. This minimizes the risk of introducing bugs that could disrupt the application or require costly fixes later. Automated deployment pipelines also reduce the likelihood of human error during the release process, ensuring that deployments are consistent and predictable.</p>
<p>CI/CD also fosters better collaboration among team members. In traditional development workflows, integrating code changes from multiple developers can be a time-consuming and error-prone process. With CI/CD, code is integrated and tested frequently, often multiple times a day. This means that conflicts are detected and resolved early, and the codebase remains in a stable state. As a result, teams can work more efficiently and with greater confidence, even when multiple contributors are working on different parts of the project simultaneously.</p>
<p>Finally, CI/CD supports continuous improvement and innovation. By automating the deployment process, teams can release updates to production more frequently and with less risk. This enables them to gather feedback from users faster and iterate on their products more effectively.</p>
<h3 id="heading-what-well-cover-in-this-tutorial">What We’ll Cover in This Tutorial</h3>
<p>In this tutorial, we’ll walk through the process of setting up a simple CI/CD pipeline that automates the deployment of code changes from a Bitbucket repository to a Linux server. Here’s what you’ll learn:</p>
<ol>
<li><p>How to configure a Bitbucket repository to send webhook notifications whenever there’s a push or merge to a specific branch.</p>
</li>
<li><p>How to set up a Flask-based Python server on your Linux server to listen for incoming webhook events.</p>
</li>
<li><p>How to write a script that pulls the latest changes from the repository and deploys them to the server.</p>
</li>
<li><p>How to test and troubleshoot your automated deployment process.</p>
</li>
</ol>
<p>By the end of this tutorial, you’ll have a working example of a basic CI/CD pipeline that you can customize and expand as needed. Let’s get started!</p>
<h2 id="heading-step-1-set-up-a-webhook-in-bitbucket"><strong>Step 1: Set Up a Webhook in Bitbucket</strong></h2>
<p>Before starting with the setup, let’s briefly explain what a <strong>webhook</strong> is and how it fits into our CI/CD process.</p>
<p>A webhook is a mechanism that allows one system to notify another system about an event in real-time. In the context of Bitbucket, a webhook can be configured to send an HTTP request (often a POST request with payload data) to a specified URL whenever a specific event occurs in your repository, such as a push to a branch or a pull request merge.</p>
<p>In our case, the webhook will notify our Flask-based Python server (running on your Linux server) whenever there’s a push or merge to a specific branch. This notification will trigger a script on the server to pull the latest changes from the repository and deploy them automatically. Essentially, the webhook acts as the bridge between Bitbucket and your server, enabling seamless automation of the deployment process.</p>
<p>Now that you understand the role of a webhook, let’s set one up in Bitbucket:</p>
<ol>
<li><p>Log in to Bitbucket and navigate to your repository.</p>
</li>
<li><p>On the left-hand sidebar, click on <strong>Settings</strong>.</p>
</li>
<li><p>Under the <strong>Workflow</strong> section, find and click on <strong>Webhooks</strong>.</p>
</li>
<li><p>Click the <strong>Add webhook</strong> button.</p>
</li>
<li><p>Enter a name for your webhook (for example, "Automatic Pull").</p>
</li>
<li><p>In the <strong>URL</strong> field, provide the URL to your server where the webhook will send the request. If you’re running a Flask app locally, this would be something like <a target="_blank" href="http://your-server-ip/pull-repo"><code>http://your-server-ip/pull-repo</code></a>. (For production environments, it’s highly recommended to use HTTPS to secure the communication between Bitbucket and your server.)</p>
</li>
<li><p>In the <strong>Triggers</strong> section, choose the events you want to listen to. For this example, we will select <strong>Push</strong> (and optionally, <strong>Pull Request Merged</strong> if you want to deploy after merges, too).</p>
</li>
<li><p>Save the webhook with a self-explanatory name so it’s easy to identify later.</p>
</li>
</ol>
<p>Once the webhook is set up, Bitbucket will send a POST request to the specified URL every time the selected event occurs. In the next steps, we’ll set up a Flask server to handle these incoming requests and trigger the deployment process.</p>
<p>Here is what you should see when you setup up the Bitbucket webhook</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1738092826221/e0d96fd3-d843-4064-a08d-4de95b985800.png" alt="Bitbucket screen showing the user the creation of a webhook, where your server will pull the modifications when you push or merge in your reposiroty." class="image--center mx-auto" width="909" height="882" loading="lazy"></p>
<h2 id="heading-step-2-set-up-the-flask-listener-on-your-linux-server"><strong>Step 2: Set Up the Flask Listener on Your Linux Server</strong></h2>
<p>In the next step, you’ll set up a simple web server on your Linux machine that will listen for the webhook from Bitbucket. When it receives the notification, it will execute a <code>git pull</code> or a force pull (in case of local changes) to update the repository.</p>
<h3 id="heading-install-flask"><strong>Install Flask:</strong></h3>
<p>To create the Flask application, first install Flask by running:</p>
<pre><code class="lang-bash">pip install flask
</code></pre>
<h3 id="heading-create-the-flask-app"><strong>Create the Flask App:</strong></h3>
<p>Create a new Python script (for example, <a target="_blank" href="http://app.py"><code>app_repo_pull.py</code></a>) on your server and add the following code:</p>
<pre><code class="lang-python"><span class="hljs-keyword">from</span> flask <span class="hljs-keyword">import</span> Flask
<span class="hljs-keyword">import</span> subprocess

app = Flask(__name__)

<span class="hljs-meta">@app.route('/pull-repo', methods=['POST'])</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">pull_repo</span>():</span>
    <span class="hljs-keyword">try</span>:
        <span class="hljs-comment"># Fetch the latest changes from the remote repository</span>
        subprocess.run([<span class="hljs-string">"git"</span>, <span class="hljs-string">"-C"</span>, <span class="hljs-string">"/path/to/your/repository"</span>, <span class="hljs-string">"fetch"</span>], check=<span class="hljs-literal">True</span>)
        <span class="hljs-comment"># Force reset the local branch to match the remote 'test' branch</span>
        subprocess.run([<span class="hljs-string">"git"</span>, <span class="hljs-string">"-C"</span>, <span class="hljs-string">"/path/to/your/repository"</span>, <span class="hljs-string">"reset"</span>, <span class="hljs-string">"--hard"</span>, <span class="hljs-string">"origin/test"</span>], check=<span class="hljs-literal">True</span>)  <span class="hljs-comment"># Replace 'test' with your branch name</span>
        <span class="hljs-keyword">return</span> <span class="hljs-string">"Force pull successful"</span>, <span class="hljs-number">200</span>
    <span class="hljs-keyword">except</span> subprocess.CalledProcessError:
        <span class="hljs-keyword">return</span> <span class="hljs-string">"Failed to force pull the repository"</span>, <span class="hljs-number">500</span>

<span class="hljs-keyword">if</span> __name__ == <span class="hljs-string">'__main__'</span>:
    app.run(host=<span class="hljs-string">'0.0.0.0'</span>, port=<span class="hljs-number">5000</span>)
</code></pre>
<p>Here’s what this code does:</p>
<ul>
<li><p><a target="_blank" href="http://subprocess.run"><code>subprocess.run</code></a><code>(["git", "-C", "/path/to/your/repository", "fetch"])</code>: This command fetches the latest changes from the remote repository without affecting the local working directory.</p>
</li>
<li><p><a target="_blank" href="http://subprocess.run"><code>subprocess.run</code></a><code>(["git", "-C", "/path/to/your/repository", "reset", "--hard", "origin/test"])</code>: This command performs a hard reset, forcing the local repository to match the remote <code>test</code> branch. Replace <code>test</code> with the name of your branch.</p>
</li>
</ul>
<p>Make sure to replace <code>/path/to/your/repository</code> with the actual path to your local Git repository.</p>
<h2 id="heading-step-3-expose-the-flask-app-optional"><strong>Step 3: Expose the Flask App (Optional)</strong></h2>
<p>If you want the Flask app to be accessible from outside your server, you need to expose it publicly. For this, you can set up a reverse proxy with NGINX. Here's how to do that:</p>
<p>First, install NGINX if you don't have it already by running this command:</p>
<pre><code class="lang-bash">sudo apt-get install nginx
</code></pre>
<p>Next, you’ll need to configure NGINX to proxy requests to your Flask app. Open the NGINX configuration file:</p>
<pre><code class="lang-bash">sudo nano /etc/nginx/sites-available/default
</code></pre>
<p>Modify the configuration to include this block:</p>
<pre><code class="lang-bash">server {
    listen 80;
    server_name your-server-ip;

    location /pull-repo {
        proxy_pass http://localhost:5000;
        proxy_set_header Host <span class="hljs-variable">$host</span>;
        proxy_set_header X-Real-IP <span class="hljs-variable">$remote_addr</span>;
        proxy_set_header X-Forwarded-For <span class="hljs-variable">$proxy_add_x_forwarded_for</span>;
        proxy_set_header X-Forwarded-Proto <span class="hljs-variable">$scheme</span>;
    }
}
</code></pre>
<p>Now just reload NGINX to apply the changes:</p>
<pre><code class="lang-bash">sudo systemctl reload nginx
</code></pre>
<h2 id="heading-step-4-test-the-setup"><strong>Step 4: Test the Setup</strong></h2>
<p>Now that everything is set up, go ahead and start the Flask app by executing this Python script:</p>
<pre><code class="lang-bash">python3 app_repo_pull.py
</code></pre>
<p>Now to test if everything is working:</p>
<ol>
<li><strong>Make a commit</strong>: Push a commit to the <code>test</code> branch in your Bitbucket repository. This action will trigger the webhook.</li>
</ol>
<ol>
<li><p><strong>Webhook trigger</strong>: The webhook will send a POST request to your server. The Flask app will receive this request, perform a force pull from the <code>test</code> branch, and update the local repository.</p>
</li>
<li><p><strong>Verify the pull</strong>: Check the log output of your Flask app or inspect the local repository to verify that the changes have been pulled and applied successfully.</p>
</li>
</ol>
<h2 id="heading-step-5-security-considerations"><strong>Step 5: Security Considerations</strong></h2>
<p>When exposing a Flask app to the internet, securing your server and application is crucial to protect it from unauthorized access, data breaches, and attacks. Here are the key areas to focus on:</p>
<h4 id="heading-1-use-a-secure-server-with-proper-firewall-rules"><strong>1. Use a Secure Server with Proper Firewall Rules</strong></h4>
<p>A secure server is one that is configured to minimize exposure to external threats. This involves using firewall rules, minimizing unnecessary services, and ensuring that only required ports are open for communication.</p>
<h5 id="heading-example-of-a-secure-server-setup"><strong>Example of a secure server setup:</strong></h5>
<ul>
<li><p><strong>Minimal software</strong>: Only install the software you need (for example, Python, Flask, NGINX) and remove unnecessary services.</p>
</li>
<li><p><strong>Operating system updates</strong>: Ensure your server's operating system is up-to-date with the latest security patches.</p>
</li>
<li><p><strong>Firewall configuration</strong>: Use a firewall to control incoming and outgoing traffic and limit access to your server.</p>
</li>
</ul>
<p>For example, a basic <strong>UFW (Uncomplicated Firewall)</strong> configuration on Ubuntu might look like this:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Allow SSH (port 22) for remote access</span>
sudo ufw allow ssh

<span class="hljs-comment"># Allow HTTP (port 80) and HTTPS (port 443) for web traffic</span>
sudo ufw allow http
sudo ufw allow https

<span class="hljs-comment"># Enable the firewall</span>
sudo ufw <span class="hljs-built_in">enable</span>

<span class="hljs-comment"># Check the status of the firewall</span>
sudo ufw status
</code></pre>
<p>In this case:</p>
<ul>
<li><p>The firewall allows incoming SSH connections on port 22, HTTP on port 80, and HTTPS on port 443.</p>
</li>
<li><p>Any unnecessary ports or services should be blocked by default to limit exposure to attacks.</p>
</li>
</ul>
<h5 id="heading-additional-firewall-rules"><strong>Additional Firewall Rules:</strong></h5>
<ul>
<li><p><strong>Limit access to webhook endpoint</strong>: Ideally, only allow traffic to the webhook endpoint from Bitbucket's IP addresses to prevent external access. You can set this up in your firewall or using your web server (for example, NGINX) by only accepting requests from Bitbucket's IP range.</p>
</li>
<li><p><strong>Deny all other incoming traffic</strong>: For any service that does not need to be exposed to the internet (for example, database ports), ensure those ports are blocked.</p>
</li>
</ul>
<h4 id="heading-2-add-authentication-to-the-flask-app"><strong>2. Add Authentication to the Flask App</strong></h4>
<p>Since your Flask app will be publicly accessible via the webhook URL, you should consider adding authentication to ensure only authorized users (such as Bitbucket's servers) can trigger the pull.</p>
<h5 id="heading-basic-authentication-example"><strong>Basic Authentication Example:</strong></h5>
<p>You can use a simple token-based authentication to secure your webhook endpoint. Here’s an example of how to modify your Flask app to require an authentication token:</p>
<pre><code class="lang-python"><span class="hljs-keyword">from</span> flask <span class="hljs-keyword">import</span> Flask, request, abort
<span class="hljs-keyword">import</span> subprocess

app = Flask(__name__)

<span class="hljs-comment"># Define a secret token for webhook verification</span>
SECRET_TOKEN = <span class="hljs-string">'your-secret-token'</span>

<span class="hljs-meta">@app.route('/pull-repo', methods=['POST'])</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">pull_repo</span>():</span>
    <span class="hljs-comment"># Check if the request contains the correct token</span>
    token = request.headers.get(<span class="hljs-string">'X-Hub-Signature'</span>)
    <span class="hljs-keyword">if</span> token != SECRET_TOKEN:
        abort(<span class="hljs-number">403</span>)  <span class="hljs-comment"># Forbidden if the token is incorrect</span>

    <span class="hljs-keyword">try</span>:
        subprocess.run([<span class="hljs-string">"git"</span>, <span class="hljs-string">"-C"</span>, <span class="hljs-string">"/path/to/your/repository"</span>, <span class="hljs-string">"fetch"</span>], check=<span class="hljs-literal">True</span>)
        subprocess.run([<span class="hljs-string">"git"</span>, <span class="hljs-string">"-C"</span>, <span class="hljs-string">"/path/to/your/repository"</span>, <span class="hljs-string">"reset"</span>, <span class="hljs-string">"--hard"</span>, <span class="hljs-string">"origin/test"</span>], check=<span class="hljs-literal">True</span>)
        <span class="hljs-keyword">return</span> <span class="hljs-string">"Force pull successful"</span>, <span class="hljs-number">200</span>
    <span class="hljs-keyword">except</span> subprocess.CalledProcessError:
        <span class="hljs-keyword">return</span> <span class="hljs-string">"Failed to force pull the repository"</span>, <span class="hljs-number">500</span>

<span class="hljs-keyword">if</span> __name__ == <span class="hljs-string">'__main__'</span>:
    app.run(host=<span class="hljs-string">'0.0.0.0'</span>, port=<span class="hljs-number">5000</span>)
</code></pre>
<h5 id="heading-how-it-works"><strong>How it works:</strong></h5>
<ul>
<li><p>The <code>X-Hub-Signature</code> is a custom header that you add to the request when setting up the webhook in Bitbucket.</p>
</li>
<li><p>Only requests with the correct token will be allowed to trigger the pull. If the token is missing or incorrect, the request is rejected with a <code>403 Forbidden</code> response.</p>
</li>
</ul>
<p>You can also use more complex forms of authentication, such as OAuth or HMAC (Hash-based Message Authentication Code), but this simple token approach works for many cases.</p>
<h4 id="heading-3-use-https-for-secure-communication"><strong>3. Use HTTPS for Secure Communication</strong></h4>
<p>It’s crucial to encrypt the data transmitted between your Flask app and the Bitbucket webhook, as well as any sensitive data (such as tokens or passwords) being transmitted over the network. This ensures that attackers cannot intercept or modify the data.</p>
<h5 id="heading-why-https"><strong>Why HTTPS?</strong></h5>
<ul>
<li><p><strong>Data encryption</strong>: HTTPS encrypts the communication, ensuring that sensitive data like your authentication token is not exposed to man-in-the-middle attacks.</p>
</li>
<li><p><strong>Trust and integrity</strong>: HTTPS helps ensure that the data received by your server hasn’t been tampered with.</p>
</li>
</ul>
<h5 id="heading-using-lets-encrypt-to-secure-your-flask-app-with-ssl"><strong>Using Let’s Encrypt to Secure Your Flask App with SSL:</strong></h5>
<ol>
<li><strong>Install Certbot</strong> (the tool for obtaining Let’s Encrypt certificates):</li>
</ol>
<pre><code class="lang-bash">sudo apt-get update
sudo apt-get install certbot python3-certbot-nginx
</code></pre>
<p><strong>Obtain a free SSL certificate for your domain</strong>:</p>
<pre><code class="lang-bash">sudo certbot --nginx -d your-domain.com
</code></pre>
<ul>
<li><p>This command will automatically configure Nginx to use HTTPS with a free SSL certificate from Let’s Encrypt.</p>
</li>
<li><p><strong>Ensure HTTPS is used</strong>: Make sure that your Flask app or Nginx configuration forces all traffic to use HTTPS. You can do this by setting up a redirection rule in Nginx:</p>
</li>
</ul>
<pre><code class="lang-bash">server {
    listen 80;
    server_name your-domain.com;

    <span class="hljs-comment"># Redirect HTTP to HTTPS</span>
    <span class="hljs-built_in">return</span> 301 https://<span class="hljs-variable">$host</span><span class="hljs-variable">$request_uri</span>;
}

server {
    listen 443 ssl;
    server_name your-domain.com;

    ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;

    <span class="hljs-comment"># Other Nginx configuration...</span>
}
</code></pre>
<p><strong>Automatic Renewal</strong>: Let’s Encrypt certificates are valid for 90 days, so it’s important to set up automatic renewal:</p>
<pre><code class="lang-bash">sudo certbot renew --dry-run
</code></pre>
<p>This command tests the renewal process to make sure everything is working.</p>
<h4 id="heading-4-logging-and-monitoring"><strong>4. Logging and Monitoring</strong></h4>
<p>Implement logging and monitoring for your Flask app to track any unauthorized attempts, errors, or unusual activity:</p>
<ul>
<li><p><strong>Log requests</strong>: Log all incoming requests, including the IP address, request headers, and response status, so you can monitor for any suspicious activity.</p>
</li>
<li><p><strong>Use monitoring tools</strong>: Set up tools like <strong>Prometheus</strong>, <strong>Grafana</strong>, or <strong>New Relic</strong> to monitor server performance and app health.</p>
</li>
</ul>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>In this tutorial, we explored how to set up a simple, beginner-friendly CI/CD pipeline that automates deployments using Bitbucket, a Linux server, and Python with Flask. Here’s a recap of what you’ve learned:</p>
<ol>
<li><p><strong>CI/CD Fundamentals</strong>: We discussed the basics of Continuous Integration (CI) and Continuous Delivery/Deployment (CD), which are essential practices for automating the integration, testing, and deployment of code. You learned how CI/CD helps speed up development, reduce errors, and improve collaboration among developers.</p>
</li>
<li><p><strong>Setting Up Bitbucket Webhooks</strong>: You learned how to configure a Bitbucket webhook to notify your server whenever there’s a push or merge to a specific branch. This webhook serves as a trigger to initiate the deployment process automatically.</p>
</li>
<li><p><strong>Creating a Flask-based Webhook Listener</strong>: We showed you how to set up a Flask app on your Linux server to listen for incoming webhook requests from Bitbucket. This Flask app receives the notifications and runs the necessary Git commands to pull and deploy the latest changes.</p>
</li>
<li><p><strong>Automating the Deployment Process</strong>: Using Python and Flask, we automated the process of pulling changes from the Bitbucket repository and performing a force pull to ensure the latest code is deployed. You also learned how to configure the server to expose the Flask app and accept requests securely.</p>
</li>
<li><p><strong>Security Considerations</strong>: We covered critical security steps to protect your deployment process:</p>
<ul>
<li><p><strong>Firewall Rules</strong>: We discussed configuring firewall rules to limit exposure and ensure only authorized traffic (from Bitbucket) can access your server.</p>
</li>
<li><p><strong>Authentication</strong>: We added token-based authentication to ensure only authorized requests can trigger deployments.</p>
</li>
<li><p><strong>HTTPS</strong>: We explained how to secure the communication between your server and Bitbucket using SSL certificates from Let's Encrypt.</p>
</li>
<li><p><strong>Logging and Monitoring</strong>: Lastly, we recommended setting up logging and monitoring to keep track of any unusual activity or errors.</p>
</li>
</ul>
</li>
</ol>
<h3 id="heading-next-steps"><strong>Next Steps</strong></h3>
<p>By the end of this tutorial, you now have a working example of an automated deployment pipeline. While this is a basic implementation, it serves as a foundation you can build on. As you grow more comfortable with CI/CD, you can explore advanced topics like:</p>
<ul>
<li><p>Multi-stage deployment pipelines</p>
</li>
<li><p>Integration with containerization tools like Docker</p>
</li>
<li><p>More complex testing and deployment strategies</p>
</li>
<li><p>Use of orchestration tools like Kubernetes for scaling</p>
</li>
</ul>
<p>CI/CD practices are continually evolving, and by mastering the basics, you’ve set yourself up for success as you expand your skills in this area. Happy automating and thank you for reading!</p>
<p>You can <a target="_blank" href="https://github.com/jpromanonet/ci_cd_fcc/tree/main">fork the code from here</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Automate Documentation Conversion with Pandoc in CI/CD Pipelines ]]>
                </title>
                <description>
                    <![CDATA[ In any software project, documentation plays a crucial role in guiding developers, users, and stakeholders through the project's features and functionalities. As projects grow and evolve, managing documentation across various formats—whether it’s mar... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-automate-documentation-conversion-with-pandoc-in-cicd-pipelines/</link>
                <guid isPermaLink="false">671949e0521b33716a071ccc</guid>
                
                    <category>
                        <![CDATA[ documentation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Preston Mayieka ]]>
                </dc:creator>
                <pubDate>Wed, 23 Oct 2024 19:09:20 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1729707865753/d1703b32-ccb4-4cd1-9c3e-3e66fef7e02f.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In any software project, documentation plays a crucial role in guiding developers, users, and stakeholders through the project's features and functionalities.</p>
<p>As projects grow and evolve, managing documentation across various formats—whether it’s markdown, HTML, or PDF for offline use—can become a time-consuming and error-prone task.</p>
<p><a target="_blank" href="https://pandoc.org/"><strong>Pandoc</strong></a> is a powerful tool that allows you to convert documentation between formats seamlessly.</p>
<p>Still, even with Pandoc, manually converting files for every update can become a bottleneck in large projects or teams where documentation is frequently updated.</p>
<p>In this article, I’ll guide you through setting up shell scripts, using Makefiles, and integrating Pandoc into CI/CD pipelines to streamline your workflow and keep your documentation up-to-date with minimal effort.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a target="_blank" href="https://freecodecamp.org/news/how-to-automate-documentation-conversion-with-pandoc-in-cicd-pipelines#Why-Automate-Documentation-Conversion">Why Automate Documentation Conversion</a>?</p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-automate-pandoc-using-shell-scripts">How to Automate Pandoc using Shell Scripts</a></p>
</li>
<li><p><a target="_blank" href="https://freecodecamp.org/news/how-to-automate-documentation-conversion-with-pandoc-in-cicd-pipelines#basic-shell-script-for-pandoc-conversion">Basic Shell Script for Pandoc Conversion</a></p>
</li>
<li><p><a target="_blank" href="https://freecodecamp.org/news/how-to-automate-documentation-conversion-with-pandoc-in-cicd-pipelines#customizing-the-script-for-several-files">Customizing the Script for Several Files</a></p>
</li>
<li><p><a target="_blank" href="https://freecodecamp.org/news/how-to-automate-documentation-conversion-with-pandoc-in-cicd-pipelines#automating-with-makefiles">Automating with Makefiles</a></p>
</li>
<li><p><a target="_blank" href="https://freecodecamp.org/news/how-to-automate-documentation-conversion-with-pandoc-in-cicd-pipelines#a-makefile-for-pandoc-conversions">A Makefile for Pandoc Conversions</a></p>
</li>
<li><p><a target="_blank" href="https://freecodecamp.org/news/how-to-automate-documentation-conversion-with-pandoc-in-cicd-pipelines#defining-dependencies-in-makefiles">Defining Dependencies in Makefiles</a></p>
</li>
<li><p><a target="_blank" href="https://freecodecamp.org/news/how-to-automate-documentation-conversion-with-pandoc-in-cicd-pipelines#why-use-makefiles-for-pandoc-automation">Why Use Makefiles for Pandoc Automation?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-integrate-pandoc-with-ci-cd-pipelines">How to Integrate Pandoc with CI/CD Pipelines</a></p>
</li>
<li><p><a target="_blank" href="https://freecodecamp.org/news/how-to-automate-documentation-conversion-with-pandoc-in-cicd-pipelines#example-setting-up-pandoc-with-github-actions">Example: Setting Up Pandoc with GitHub Actions</a></p>
</li>
<li><p><a target="_blank" href="https://freecodecamp.org/news/how-to-automate-documentation-conversion-with-pandoc-in-cicd-pipelines#triggering-on-documentation-updates">Triggering on Documentation Updates</a></p>
</li>
<li><p><a target="_blank" href="https://freecodecamp.org/news/how-to-automate-documentation-conversion-with-pandoc-in-cicd-pipelines#adapting-for-gitlab-ci-or-jenkins">Adapting for GitLab CI or Jenkins</a></p>
</li>
<li><p><a target="_blank" href="https://freecodecamp.org/news/how-to-automate-documentation-conversion-with-pandoc-in-cicd-pipelines#advanced-automation-techniques">Advanced Automation Techniques</a></p>
</li>
<li><p><a target="_blank" href="https://freecodecamp.org/news/how-to-automate-documentation-conversion-with-pandoc-in-cicd-pipelines#automating-with-cron-jobs">Automating with Cron Jobs</a></p>
</li>
<li><p><a target="_blank" href="https://freecodecamp.org/news/how-to-automate-documentation-conversion-with-pandoc-in-cicd-pipelines#ensuring-consistency-with-docker">Ensuring Consistency with Docker</a></p>
</li>
<li><p><a target="_blank" href="https://freecodecamp.org/news/how-to-automate-documentation-conversion-with-pandoc-in-cicd-pipelines#combining-tools-for-efficiency">Combining Tools for Efficiency</a></p>
</li>
<li><p><a target="_blank" href="https://freecodecamp.org/news/how-to-automate-documentation-conversion-with-pandoc-in-cicd-pipelines#conclusion">Conclusion</a></p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1729625689479/bfba6404-0a0c-4dab-9a15-0058b33c3c0c.png" alt="bfba6404-0a0c-4dab-9a15-0058b33c3c0c" class="image--center mx-auto" width="512" height="512" loading="lazy"></p>
<h2 id="heading-why-automate-documentation-conversion">Why Automate Documentation Conversion?</h2>
<p>Manually converting documentation between formats in large projects can become a daunting task.</p>
<p>Whether you're generating <strong>HTML</strong>, <strong>PDF</strong>, or <strong>DOCX</strong> versions from the same source, repeating this process for every update leads to various challenges:</p>
<ul>
<li><p><strong>Time-Consuming</strong>: Running manual commands to convert documentation every time you make a change eats into valuable development time when updates happen often.</p>
</li>
<li><p><strong>Prone to Errors</strong>: The manual process increases the likelihood of mistakes, such as using incorrect commands, missing steps, or generating outdated versions of your documentation. These inconsistencies can confuse both developers and end-users.</p>
</li>
<li><p><strong>Difficult to Scale</strong>: As projects grow in size, managing documents across different formats without automation can become unmanageable. Teams working in parallel may struggle to keep documentation synchronized, leading to mismatches between formats.</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1729627548454/96b38163-5eda-4412-83fb-483180410c61.png" alt="96b38163-5eda-4412-83fb-483180410c61" class="image--center mx-auto" width="926" height="509" loading="lazy"></p>
<p>By automating the conversion process with tools like Pandoc, you can overcome these challenges and enjoy a range of benefits:</p>
<ul>
<li><p><strong>Consistency</strong>: Automation ensures that all versions of your documentation are always up-to-date and accurate. No matter the number of formats you're generating, the process remains standardized.</p>
</li>
<li><p><strong>Efficiency</strong>: Automated workflows free up time by handling repetitive tasks in the background, allowing teams to focus on development rather than manually managing documentation updates.</p>
</li>
<li><p><strong>Scalability</strong>: With automation, it’s straightforward to scale documentation efforts as your project grows. Whether you're maintaining a single document or an entire library of resources, automation makes sure everything stays synchronized with minimal effort.</p>
</li>
</ul>
<p>The next section explores how to automate the conversion process.</p>
<h2 id="heading-how-to-automate-pandoc-using-shell-scripts">How to Automate Pandoc using Shell Scripts</h2>
<p>By using <a target="_blank" href="https://medium.com/@jadhav.swatissj99/introduction-to-shell-scripting-automate-your-workflow-efficiently-d9415537e990">shell scripts</a>, you can streamline the process of running <a target="_blank" href="https://pandoc.org/getting-started.html#step-6-converting-a-file">PanDoc commands</a>, saving time and reducing the risk of errors associated with manual command execution.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1729627613042/5a2b2c62-3f67-42a5-81fe-5d4ff58714c3.png" alt="5a2b2c62-3f67-42a5-81fe-5d4ff58714c3" class="image--center mx-auto" width="798" height="744" loading="lazy"></p>
<h3 id="heading-basic-shell-script-for-pandoc-conversion">Basic Shell Script for PanDoc Conversion</h3>
<p>To get started, we’ll create a shell script that converts a single Markdown file into various formats.</p>
<p>For instance, here’s a script that converts <code>input.md</code> into HTML and PDF:</p>
<pre><code class="lang-bash"><span class="hljs-meta">#!/bin/bash</span>

<span class="hljs-comment"># Convert input.md to HTML and PDF</span>
pandoc input.md -o output.html
pandoc input.md -o output.pdf

<span class="hljs-built_in">echo</span> <span class="hljs-string">"Conversion complete!"</span>
</code></pre>
<p>In the above script:</p>
<ul>
<li><p>The <code>#!/bin/bash</code> line indicates that the script should be run using the Bash shell.</p>
</li>
<li><p>The pandoc commands convert the <code>input.md</code> file into <code>output.html</code> and <code>output.pdf</code>.</p>
</li>
<li><p>The <code>echo</code> command confirms that the conversion process is complete.</p>
</li>
</ul>
<h3 id="heading-customizing-the-script-for-several-files">Customizing the Script for Several Files</h3>
<p>If you want to convert all Markdown files in a directory, you can customize your script to process several files at once.</p>
<p>For instance, here’s a script that converts <code>input.md</code> into <code>HTML</code> and <code>PDF</code>:</p>
<pre><code class="lang-bash"><span class="hljs-meta">#!/bin/bash</span>

<span class="hljs-comment"># Go through all the Markdown files present in the current directory</span>
<span class="hljs-keyword">for</span> file <span class="hljs-keyword">in</span> *.md; <span class="hljs-keyword">do</span>
  <span class="hljs-comment"># Convert each Markdown file to PDF</span>
  pandoc <span class="hljs-string">"<span class="hljs-variable">$file</span>"</span> -o <span class="hljs-string">"<span class="hljs-variable">${file%.md}</span>.pdf"</span>
<span class="hljs-keyword">done</span>

<span class="hljs-built_in">echo</span> <span class="hljs-string">"All conversions complete!"</span>
</code></pre>
<p>In this script:</p>
<ul>
<li><p>The <code>for</code> loop iterates over each <code>.md</code> file in the current directory.</p>
</li>
<li><p>The <code>pandoc</code> command converts each Markdown file to a PDF, preserving the original filename but changing the extension to <code>.pdf</code> with the <code>${file%.md}.pdf</code> syntax.</p>
</li>
<li><p>The final <code>echo</code> confirms that all conversions are complete.</p>
</li>
</ul>
<h3 id="heading-adding-error-handling-and-complex-logic">Adding Error Handling and Complex Logic</h3>
<p>To enhance your script's robustness, you can add error handling and extra logic.</p>
<p>For instance, perhaps you want to make sure that Pandoc is installed before proceeding, and handle cases where the input file may be missing.</p>
<p>The script will be as follows:</p>
<pre><code class="lang-bash"><span class="hljs-meta">#!/bin/bash</span>

<span class="hljs-comment"># Check if Pandoc is installed</span>
<span class="hljs-keyword">if</span> ! <span class="hljs-built_in">command</span> -v pandoc &amp;&gt; /dev/null; <span class="hljs-keyword">then</span>
  <span class="hljs-built_in">echo</span> <span class="hljs-string">"Pandoc is not installed. Please install it and try again."</span>
  <span class="hljs-built_in">exit</span> 1
<span class="hljs-keyword">fi</span>

<span class="hljs-comment"># Go through all the Markdown files present in the current directory</span>
<span class="hljs-keyword">for</span> file <span class="hljs-keyword">in</span> *.md; <span class="hljs-keyword">do</span>
  <span class="hljs-keyword">if</span> [ -e <span class="hljs-string">"<span class="hljs-variable">$file</span>"</span> ]; <span class="hljs-keyword">then</span>
    <span class="hljs-comment"># Convert each Markdown file to PDF</span>
    pandoc <span class="hljs-string">"<span class="hljs-variable">$file</span>"</span> -o <span class="hljs-string">"<span class="hljs-variable">${file%.md}</span>.pdf"</span>
    <span class="hljs-built_in">echo</span> <span class="hljs-string">"Converted <span class="hljs-variable">$file</span> to PDF."</span>
  <span class="hljs-keyword">else</span>
    <span class="hljs-built_in">echo</span> <span class="hljs-string">"No Markdown files found."</span>
  <span class="hljs-keyword">fi</span>
<span class="hljs-keyword">done</span>

<span class="hljs-built_in">echo</span> <span class="hljs-string">"All conversions complete!"</span>
</code></pre>
<p>In this enhanced script:</p>
<ul>
<li><p>The <code>if ! command -v pandoc &amp;&gt; /dev/null; then</code> line checks whether Pandoc is installed.</p>
</li>
<li><p>The <code>if [ -e "$file" ]; then</code> statement checks if each Markdown file exists before attempting to convert it.</p>
</li>
<li><p>An informative message is printed after each successful conversion, providing feedback on the process.</p>
</li>
</ul>
<p>As your project grows in complexity, relying solely on shell scripts for automation can become difficult to manage when dealing with lots of files and frequent updates.</p>
<p>This is where <strong>Makefiles</strong> come in handy.</p>
<h3 id="heading-automating-with-makefiles">Automating with Makefiles</h3>
<p>A <a target="_blank" href="https://opensource.com/article/18/8/what-how-makefile">Makefile</a> is a special file that defines rules and commands for automating tasks in a project.</p>
<p>It’s widely used by developers to <strong>compile code</strong>, but developers can also leverage it for non-compilation tasks, such as converting documentation formats with Pandoc.</p>
<h3 id="heading-a-makefile-for-pandoc-conversions">A Makefile for Pandoc Conversions</h3>
<p>Here’s an example of how you can create a Makefile to automate Pandoc conversions:</p>
<pre><code class="lang-makefile"><span class="hljs-section">all: html pdf</span>

<span class="hljs-section">html:</span>
    pandoc input.md -o output.html

<span class="hljs-section">pdf:</span>
    pandoc input.md -o output.pdf
</code></pre>
<p>In this Makefile:</p>
<ul>
<li><p><code>all</code> is the default target. When you run <code>make</code> without specifying a target, it will run the <code>html</code> and <code>pdf</code> targets sequentially.</p>
</li>
<li><p>The <code>html</code> target runs a PanDoc command to convert <a target="_blank" href="http://input.md"><code>input.md</code></a> into <code>output.html</code>.</p>
</li>
<li><p>The <code>pdf</code> target runs a PanDoc command to convert <a target="_blank" href="http://input.md"><code>input.md</code></a> into <code>output.pdf</code>.</p>
</li>
</ul>
<p>To use this Makefile, run the following command in your terminal:</p>
<pre><code class="lang-bash">codemake
</code></pre>
<p>This will execute both the <code>html</code> and <code>pdf</code> targets, converting the Markdown file into both formats.</p>
<h3 id="heading-defining-dependencies-in-makefiles">Defining Dependencies in Makefiles</h3>
<p>One of the major strengths of Makefiles is their ability to handle <strong>dependencies</strong>.</p>
<p>In the context of documentation conversion, you can specify which files to update when you <strong>detect changes</strong> in the source file.</p>
<p>Let’s look at an example:</p>
<pre><code class="lang-makefile"><span class="hljs-section">output.html: input.md</span>
    pandoc input.md -o output.html

<span class="hljs-section">output.pdf: input.md</span>
    pandoc input.md -o output.pdf
</code></pre>
<p>In this Makefile:</p>
<ul>
<li><p>The <code>output.html</code> and <code>output.pdf</code> files depend on <code>input.md</code>.</p>
</li>
<li><p>If you run make output.html or make output.pdf, Pandoc will regenerate the corresponding file if you update <code>input.md</code> after the last time you created the output file.</p>
</li>
</ul>
<p>This ensures that Pandoc converts the files that have changed, saving time when working on large documentation projects.</p>
<h3 id="heading-why-use-makefiles-for-pandoc-automation">Why Use Makefiles for Pandoc Automation?</h3>
<p>Makefiles offer several advantages for automating Pandoc conversions in larger projects:</p>
<ul>
<li><p><strong>Efficiency</strong>: Makefiles rebuild what’s necessary, meaning you won’t waste time converting files that haven’t changed.</p>
</li>
<li><p><strong>Simplicity</strong>: Once set up, running <code>make</code> simplifies the conversion process to a single command, making it easier for teams to maintain consistent documentation workflows.</p>
</li>
<li><p><strong>Scalability</strong>: As your project grows, you can add more targets and dependencies to the Makefile, automating everything from documentation to more complex build processes.</p>
</li>
</ul>
<p>Makefiles are an excellent option for automating documentation conversions, when combined with Pandoc for multi-format outputs.</p>
<p>They help ensure that your workflow is <strong>efficient</strong> and your documentation remains up-to-date.</p>
<p>As modern development practices increasingly rely on <a target="_blank" href="https://www.redhat.com/en/topics/devops/what-is-ci-cd#:~:text=CI%2FCD%2C%20which%20stands%20for,a%20shared%20source%20code%20repository.">Continuous Integration and Continuous Deployment (CI/CD)</a>, automating routine tasks such as documentation generation becomes essential.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1729626428543/8194dc35-8a1a-4d91-b4b0-8b8ce7174eba.jpeg" alt="8194dc35-8a1a-4d91-b4b0-8b8ce7174eba" class="image--center mx-auto" width="3840" height="2160" loading="lazy"></p>
<p>Integrating Pandoc into your <a target="_blank" href="https://www.redhat.com/en/topics/devops/what-cicd-pipeline">CI/CD pipelines</a> allows for seamless documentation conversion and updates, ensuring that your docs are always up-to-date without manual intervention.</p>
<p>By automating this process, you can focus on coding and building, while your pipeline handles the conversion and distribution of your project documentation. Let’s explore how to set up Pandoc in a CI/CD pipeline to streamline your documentation workflows.</p>
<h2 id="heading-how-to-integrate-pandoc-with-cicd-pipelines">How to Integrate Pandoc with CI/CD Pipelines</h2>
<p>Automating documentation generation within your CI/CD pipeline brings significant benefits, including consistency, efficiency, and hands-off updates.</p>
<p>Whether you’re using <a target="_blank" href="https://github.com/features/actions">GitHub Actions</a>, <a target="_blank" href="https://about.gitlab.com/topics/ci-cd/">GitLab CI</a>, <a target="_blank" href="https://www.jenkins.io/">Jenkins</a>, or another automation tool, integrating Pandoc ensures that documentation gets generated and distributed whenever changes occur.</p>
<p>This approach reduces the risk of <strong>outdated</strong> or <strong>inconsistent</strong> documentation.</p>
<h3 id="heading-example-setting-up-pandoc-with-github-actions">Example: Setting Up Pandoc with GitHub Actions</h3>
<p>Let’s walk through a clear example of how you can use <strong>GitHub Actions</strong> to automate the process of generating documentation with Pandoc.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1729626613665/ddc1ddd8-2049-4971-8883-1f86bd80a558.png" alt="ddc1ddd8-2049-4971-8883-1f86bd80a558" class="image--center mx-auto" width="1866" height="759" loading="lazy"></p>
<p>Below is a basic workflow that converts a <strong>Markdown file (*.md)</strong> into a <strong>PDF</strong> whenever code gets pushed to the repository.</p>
<pre><code class="lang-yaml"><span class="hljs-attr">name:</span> <span class="hljs-string">Generate</span> <span class="hljs-string">Documentation</span>

<span class="hljs-attr">on:</span> [<span class="hljs-string">push</span>]

<span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">build:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>

    <span class="hljs-attr">steps:</span>
      <span class="hljs-comment"># Step 1: Checkout code from the repository</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Checkout</span> <span class="hljs-string">code</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v2</span>

      <span class="hljs-comment"># Step 2: Install Pandoc</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Install</span> <span class="hljs-string">Pandoc</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">sudo</span> <span class="hljs-string">apt-get</span> <span class="hljs-string">install</span> <span class="hljs-string">pandoc</span>

      <span class="hljs-comment"># Step 3: Convert Markdown to PDF</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Convert</span> <span class="hljs-string">Markdown</span> <span class="hljs-string">to</span> <span class="hljs-string">PDF</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">pandoc</span> <span class="hljs-string">input.md</span> <span class="hljs-string">-o</span> <span class="hljs-string">output.pdf</span>

      <span class="hljs-comment"># Step 4: Upload the generated PDF as an artifact</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Upload</span> <span class="hljs-string">Documentation</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/upload-artifact@v2</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">name:</span> <span class="hljs-string">documentation</span>
          <span class="hljs-attr">path:</span> <span class="hljs-string">output.pdf</span>
</code></pre>
<p>Here’s a breakdown of each step:</p>
<ul>
<li><p><strong>Checkout Code</strong>: The <code>actions/checkout@v2</code> action retrieves the latest code from your repository.</p>
</li>
<li><p><strong>Install Pandoc</strong>: This step installs Pandoc on the runner (in this case, an Ubuntu machine) so that it can gets used for the documentation conversion.</p>
</li>
<li><p><strong>Convert Markdown to PDF</strong>: The PanDoc command converts the <a target="_blank" href="http://input.md"><code>input.md</code></a> file into <code>output.pdf</code>.</p>
</li>
<li><p><strong>Upload Documentation</strong>: This step saves the generated PDF as an artifact in the CI/CD pipeline, making it downloadable or accessible later</p>
</li>
</ul>
<h3 id="heading-triggering-on-documentation-updates">Triggering on Documentation Updates</h3>
<p>You can configure your CI/CD pipeline to trigger when documentation updates occur, for example, when changes get pushed to a <code>docs</code> branch:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">on:</span>
  <span class="hljs-attr">push:</span>
    <span class="hljs-attr">branches:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">docs</span>
</code></pre>
<p>This setup ensures that your documentation gets regenerated when changes are specifically made to the documentation branch, reducing unnecessary rebuilds.</p>
<h3 id="heading-adapting-for-gitlab-ci-or-jenkins">Adapting for GitLab CI or Jenkins</h3>
<p>While the above example uses GitHub Actions, the same principles work on other CI/CD systems like <strong>GitLab CI</strong> or <strong>Jenkins</strong>.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1729626674435/91e102a9-cb70-4819-bb0d-5a60f16dcdae.jpeg" alt="91e102a9-cb70-4819-bb0d-5a60f16dcdae" class="image--center mx-auto" width="5599" height="3733" loading="lazy"></p>
<p>For instance, in GitLab CI, you could define a <code>.gitlab-ci.yml</code> file that follows similar steps to check out the code, install Pandoc, convert the files, and store the resulting documentation.</p>
<p>Integrating Pandoc into your CI/CD pipeline offers a reliable way to automate your documentation workflows, ensuring that your project docs are always current, accurate, and accessible.</p>
<h2 id="heading-advanced-automation-techniques">Advanced Automation Techniques</h2>
<p>When your project reaches a stage where documentation needs to be generated frequently or across multiple environments, it’s essential to extend your automation strategy to maintain consistency and reliability.</p>
<h3 id="heading-automating-with-cron-jobs">Automating with Cron Jobs</h3>
<p>For projects where documentation updates occur frequently, but not always due to code pushes or commits, you can automate the process using <a target="_blank" href="https://en.wikipedia.org/wiki/Cron"><strong>cron jobs</strong></a>.</p>
<p>Cron allows you to <strong>schedule tasks</strong> at specific intervals, meaning your documentation can automatically get updated at set times without needing manual input.</p>
<p>For example, setting up a cron job to run every day at midnight to convert Markdown files to PDFs:</p>
<pre><code class="lang-bash">0 0 * * * /path/to/pandoc /path/to/input.md -o /path/to/output.pdf
</code></pre>
<p>With this cron job in place, Pandoc will automatically convert your Markdown files to PDFs at the specified time, ensuring that your documentation remains up-to-date without manual intervention.</p>
<h3 id="heading-ensuring-consistency-with-docker">Ensuring Consistency with Docker</h3>
<p>When working in a team or across various systems, ensuring that Pandoc runs consistently in different environments can be challenging.</p>
<p><a target="_blank" href="https://www.docker.com/">Docker</a> provides a solution to this by allowing you to package Pandoc and all its dependencies in a container, ensuring that the same setup gets used everywhere.</p>
<p>This is useful in CI/CD pipelines, where different environments (like local machines, staging, and production servers) can have different configurations.</p>
<p>You can use the official <a target="_blank" href="https://hub.docker.com/u/pandoc"><strong>Pandoc Docker image</strong></a> to simplify the setup process:</p>
<pre><code class="lang-bash">docker run --rm -v $(<span class="hljs-built_in">pwd</span>):/data pandoc/core:latest input.md -o output.pdf
</code></pre>
<p>This command runs Pandoc inside a Docker container, using the latest version of Pandoc, and mounts your current directory to the container’s <code>/data</code> directory.</p>
<p>By doing this, you ensure that no matter where the pipeline runs, the conversion will work the same way every time.</p>
<h3 id="heading-combining-tools-for-efficiency">Combining Tools for Efficiency</h3>
<p>By combining tools like <strong>cron jobs</strong> and <strong>Docker</strong>, you can set up an advanced automation pipeline that ensures:</p>
<ul>
<li><p><strong>Scheduled updates</strong>: Cron jobs trigger documentation generation at regular intervals.</p>
</li>
<li><p><strong>Consistency across environments</strong>: Docker ensures that Pandoc and its dependencies are the same on every machine.</p>
</li>
<li><p><strong>Reliability</strong>: Together, these tools help ensure that your documentation is always accurate, no matter where or when it’s generated.</p>
</li>
</ul>
<p>These advanced techniques allow you to further streamline your documentation workflows, improving both efficiency and reliability as your project grows.</p>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>Automating documentation conversion with Pandoc not only saves time but also ensures consistency and scalability as your project grows.</p>
<p>Whether you’re managing small or large projects, these techniques enable you to focus on coding and building, knowing that your documentation is automatically up-to-date and ready for distribution.</p>
<p>By embracing automation, you streamline your development process and enhance collaboration across teams, ensuring that your documentation evolves as efficiently as your code.</p>
<p>Now it’s time to apply these tools to your projects and enjoy the benefits of a fully automated documentation pipeline.</p>
<p><strong>Let’s stay in touch:</strong></p>
<ul>
<li><p><a target="_blank" href="https://www.linkedin.com/in/preston-mayieka/">Connect with me on LinkedIn</a>. I post regularly, so following me is a great idea.</p>
</li>
<li><p><a target="_blank" href="https://mobile.x.com/Preston_Mayieka">Follow me on X</a></p>
</li>
</ul>
<p>Feel free to reach out through the channels above if you have any questions. I will be happy to help.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Create Database Documentation Using dbdocs with DBML ]]>
                </title>
                <description>
                    <![CDATA[ Database documentation plays a crucial role in maintaining and scaling systems. Clear and well-organized documentation can significantly improve communication between team members and enhance project longevity. One of the most efficient ways to docum... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/create-database-documentation-using-dbdocs-with-dbml/</link>
                <guid isPermaLink="false">670d3f2e7e2c70da5a203bba</guid>
                
                    <category>
                        <![CDATA[ Databases ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ data ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Truong-Phat Nguyen ]]>
                </dc:creator>
                <pubDate>Mon, 14 Oct 2024 15:56:30 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1728620241328/79515009-0fa3-4fcd-a4ce-e1ec2d5609f8.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Database documentation plays a crucial role in maintaining and scaling systems. Clear and well-organized documentation can significantly improve communication between team members and enhance project longevity.</p>
<p>One of the most efficient ways to document a database is through <a target="_blank" href="https://dbdocs.io/"><strong>dbdocs</strong></a> and <a target="_blank" href="https://dbml.dbdiagram.io/home"><strong>DBML</strong></a> - an open sourced Database Markup Language.</p>
<p>In this guide, I’ll show you how to create database documentation using these tools, step by step.</p>
<h1 id="heading-what-is-dbdocs"><strong>What is dbdocs?</strong></h1>
<p><a target="_blank" href="https://dbdocs.io/"><strong>dbdocs</strong></a> is a platform that generates database documentation from your schema, easily shareable via a link. Using <a target="_blank" href="https://dbml.dbdiagram.io/home"><strong>DBML</strong></a> <strong>(Database Markup Language)</strong>, you can create clear, shareable, and updatable documentation of your database structure.</p>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>Before we begin, ensure you have the following:</p>
<ul>
<li><p>Basic knowledge of databases and SQL.</p>
</li>
<li><p>A database schema to document (we’ll use a PostgreSQL example in this guide).</p>
</li>
</ul>
<h2 id="heading-step-1-install-dbml-cli-and-dbdocs"><strong>Step 1: Install DBML CLI and dbdocs</strong></h2>
<p>Start by installing the <strong>DBML CLI</strong>, which helps convert your database schema into a DBML format. You also need the <strong>dbdocs CLI</strong> to generate and publish your documentation.</p>
<pre><code class="lang-bash">npm install -g dbdocs
</code></pre>
<h2 id="heading-step-2-export-your-database-schema-to-dbml"><strong>Step 2: Export Your Database Schema to DBML</strong></h2>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1728615902517/20974a9d-729e-4b3a-997c-0b89e944a6cd.png" alt="DB diagram" class="image--center mx-auto" width="1818" height="514" loading="lazy"></p>
<p>If you’re working with an existing database, you can export the schema into DBML using the DBML CLI tool.</p>
<p>For PostgreSQL, run the following command:</p>
<pre><code class="lang-bash">$ dbdocs db2dbml postgres &lt;connection-string&gt; -o database.dbml

✔ Connecting to database &lt;db-name&gt;... <span class="hljs-keyword">done</span>.
✔ Generating DBML... <span class="hljs-keyword">done</span>.
✔ Wrote to database.dbml
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1728615885904/9f68f18b-fa14-4e88-b58b-bd90d292ef31.gif" alt="Extract DBML code from database connection" class="image--center mx-auto" width="700" height="400" loading="lazy"></p>
<p>This command will export your database schema and save it into a file called <code>database.dbml</code>.</p>
<p>Here’s an example of how a generated DBML file might look:</p>
<pre><code class="lang-bash">Table users {
  id int [pk, increment]
  username varchar(50) [not null]
  email varchar(100) [not null, unique]
  created_at timestamp [not null]
}

Table orders {
  id int [pk, increment]
  user_id int [not null, ref: &gt; users.id]
  total decimal [not null]
  created_at timestamp [not null]
}
</code></pre>
<p><strong>In this example:</strong></p>
<p>• The users and orders tables are defined.</p>
<p>• Fields are annotated with types and constraints.</p>
<p>• The relationship between <code>orders.user_id</code> and <code>users.id</code> is established using <code>ref</code>.</p>
<h2 id="heading-step-3-edit-and-add-notes-to-the-dbml-file"><strong>Step 3: Edit and Add Notes to the DBML File</strong></h2>
<p>You may want to clean it up or add extra documentation like table descriptions and field descriptions to communicate with other members in the team.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1728615980279/8e1851a8-2e38-4ded-8b6a-c873d6b395b8.gif" alt="Add notes to generated DBML Code" class="image--center mx-auto" width="800" height="501" loading="lazy"></p>
<h2 id="heading-step-4-generate-documentation-with-dbdocs"><strong>Step 4: Generate Documentation with dbdocs</strong></h2>
<p>Once your DBML file is ready, the next step is to generate the documentation using dbdocs. First, you need to login to dbdocs:</p>
<pre><code class="lang-bash">dbdocs login
</code></pre>
<p>After logging in, publish the DBML file:</p>
<pre><code class="lang-bash">dbdocs build database.dbml
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1728616039961/0ef67db3-8a86-495a-b42f-3fad0fead933.gif" alt="Generate database documentation from DBML file" class="image--center mx-auto" width="800" height="499" loading="lazy"></p>
<p>This command will generate a shareable documentation link that you can access via the dbdocs platform. You can also set access permissions and collaborate with your team.</p>
<p>This seamless workflow ensures that your documentation always reflects the latest state of your database.</p>
<h1 id="heading-benefits-of-using-dbdocs-with-dbml"><strong>Benefits of Using dbdocs with DBML</strong></h1>
<ul>
<li><p><strong>Simplicity</strong>: The <a target="_blank" href="https://dbml.dbdiagram.io/home">DBML</a> syntax is simple and easy to learn, making it a perfect fit for teams.</p>
</li>
<li><p><strong>Automation</strong>: You can <a target="_blank" href="https://docs.dbdocs.io/features/generate-dbml-from-db">automate your database documentation updates</a> as part of your <a target="_blank" href="https://docs.dbdocs.io/features/ci-integration">CI/CD pipeline</a>.</p>
</li>
<li><p><strong>Collaboration</strong>: Easily <a target="_blank" href="https://docs.dbdocs.io/features/project-access-control">share documentation links</a> with your team or stakeholders for easy access and discussion.</p>
</li>
<li><p><strong>Version Control:</strong> Use <a target="_blank" href="https://docs.dbdocs.io/features/schema-changelog">schema changelog</a> to track database schema changes over time.</p>
</li>
<li><p><strong>Visualization</strong>: dbdocs provides a clean interface to visualize your database schema, relationships, and annotations. <a target="_blank" href="https://dbdocs.io/Holistics/Ecommerce">Try this demo</a> to learn more.</p>
</li>
</ul>
<h1 id="heading-conclusion"><strong>Conclusion</strong></h1>
<p>In this tutorial, we explored how to export a database schema, customize it, and generate shareable documentation using dbdocs.</p>
<p>By incorporating this workflow into your development process, you’ll improve your team’s collaboration, enhance your project’s scalability, and ensure that everyone stays on the same page. Happy documenting!</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
