<?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>Sun, 31 May 2026 09:38:27 +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 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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="600" height="400" 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="600" height="400" 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="600" height="400" 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="600" height="400" 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="600" height="400" 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>
