Module 8 — CI/CD

Automate build→test→deploy: CI/CD concepts, GitHub Actions on the lab runner, Bitbucket Pipelines and Jenkins at a glance, and GitOps with Argo CD.

Lesson: Continuous Integration & Delivery

What you'll learn

By the end you can read a pipeline diagram and explain, in plain words, what each stage does and where your code goes after you push it.

The lesson

You just wrote some code and ran it on your laptop. It works. Now what? In a real team, that code has to be combined with everyone else's code, tested, turned into something runnable, and put on a server — over and over, many times a day. Doing that by hand is slow and error-prone. CI/CD is the practice of automating that path from "I pushed code" to "it's running."

1. Continuous Integration (CI)

Integration means combining your changes with the shared codebase (usually the main branch in Git). Continuous Integration means everyone merges small changes frequently — and every merge automatically triggers a build and a test run.

The point is to catch problems early. If ten developers each work alone for two weeks and then try to combine everything (a "big bang merge"), the conflicts and bugs are painful. If instead everyone integrates several times a day, each change is small and breakage is caught within minutes.

A CI system watches your Git repository. When you push, it:

  1. Checks out your code.
  2. Builds it (compiles, or assembles a runnable package).
  3. Runs automated tests.
  4. Reports pass/fail back to you, usually as a green check or red X on your commit.

That fast pass/fail signal is the heart of CI.

2. Continuous Delivery vs Continuous Deployment

Both are abbreviated "CD", which is confusing. Here is the distinction:

   Continuous Delivery:
   commit -> build -> test -> [READY]  --(human approves)--> production

   Continuous Deployment:
   commit -> build -> test -> --------(automatic)---------> production

Continuous Delivery is the common, safer choice for most teams: automate everything up to the release, but keep a human in the loop for the final go. Continuous Deployment requires very strong automated testing and monitoring, because nobody is checking before users get the change.

3. Pipeline stages and artifacts

A pipeline is an ordered series of automated steps. A typical one looks like this:

 +---------+   +-------+   +---------+   +----------+   +----------+
 |  BUILD  |-->| TEST  |-->| PACKAGE |-->|  PUSH    |-->|  DEPLOY  |
 | compile |   | unit  |   | make    |   | upload   |   | run on   |
 | code    |   | tests |   | image   |   | artifact |   | server   |
 +---------+   +-------+   +---------+   +----------+   +----------+
      |            |            |              |              |
   if this fails, the pipeline stops here and reports red

Each box is a stage. Stages run in order; if one fails, later ones are skipped. This "fail fast" behavior means you never deploy code that didn't pass its tests.

An artifact is the concrete output a stage produces and hands to the next stage — for example a compiled binary, a .zip, or (most common today) a container image. A container image is a self-contained, runnable package of your app plus everything it needs to run.

In our lab, the package stage builds a container image and the push stage uploads it to the lab's private container registry at 10.100.100.6. A registry is just a storage server for images. Later stages (or another tool entirely) pull the image from there and run it.

4. Push vs Pull deployment

There are two ways the deploy stage actually gets software onto the target server.

  PUSH model:                          PULL model:
  +----------+                         +----------+        +-----------+
  | CI server|---kubectl apply-------> | cluster  |        | CI server |---> Git repo
  | (has prod|                         |          |        +-----------+        ^
  |  creds)  |                         +----------+                             | watches
  +----------+                                            +----------+          | & pulls
                                                          | agent in |----------+
                                                          | cluster  |
                                                          +----------+

Push is simpler to set up. Pull is safer at scale: production credentials never leave the cluster, and Git becomes the single source of truth for "what should be running."

5. Why automate at all?

Automating the build→test→deploy path gives you:

In the lab, you'll wire all of this up for real: a Gitea Actions runner on the build VM (10.100.100.11) builds your image and pushes it to the registry (10.100.100.6), and Argo CD pulls it onto the live Kubernetes cluster (master 10.100.100.7).

Dig deeper

Search terms

Check yourself

  1. In one sentence each, define Continuous Integration, Continuous Delivery, and Continuous Deployment.
  2. What is the one difference between Continuous Delivery and Continuous Deployment?
  3. Name the typical stages of a pipeline in order. What happens to later stages if one fails?
  4. What is an "artifact"? Give an example used in our lab.
  5. In a pull-based deployment, which side initiates the change — the CI server or something inside the target environment? Why is that safer?

Lesson: Pipelines with GitHub Actions

What you'll learn

By the end you can write a working build-and-push-image pipeline and run it on the lab.

The lesson

GitHub Actions is a CI/CD system built into GitHub. You describe your pipeline in a YAML file inside your repo, and GitHub runs it for you on triggers like "someone pushed code." The big reason we teach it: its workflow syntax is widely copied. The lab's Gitea server has an Actions runner that understands the same syntax, so everything you learn here runs for real on the lab — no GitHub account needed.

1. The four building blocks

GitHub Actions has a clear hierarchy. Define these terms once and the rest is easy:

 Workflow (build-and-push.yml)
 |
 +-- Job: build
 |    |
 |    +-- Step 1: checkout code
 |    +-- Step 2: log in to registry
 |    +-- Step 3: build image
 |    +-- Step 4: push image
 |
 +-- Job: notify   (could run in parallel)

A step is either a shell command (run:) or a reusable pre-built action (uses:). An action is a packaged piece of automation someone else wrote — for example actions/checkout clones your repo.

2. Triggers — the on: key

The on: key says when the workflow runs. Common triggers:

on:
  push:
    branches: [ main ]        # run when code is pushed to main
  pull_request:               # run on every PR (great for CI tests)
  workflow_dispatch:          # add a manual "Run" button
  schedule:
    - cron: '0 2 * * *'       # run nightly at 02:00

You can combine them. A common pattern is: run tests on every pull_request, but only build-and-push an image on push to main.

3. A minimal CI workflow

Here is a complete, readable workflow that runs tests on every push. Read it top to bottom:

name: CI                      # shown in the UI

on:
  push:
  pull_request:

jobs:
  test:                       # job id
    runs-on: ubuntu-latest    # which runner to use
    steps:
      - name: Check out the code
        uses: actions/checkout@v4

      - name: Run the tests
        run: |
          echo "Running tests..."
          ./run-tests.sh

runs-on: picks the runner. On GitHub this would be a GitHub-hosted machine; on the lab it selects the Gitea runner on 10.100.100.11. The | after run: lets you write a multi-line script.

4. Secrets — never hardcode credentials

To push to a registry you need a password, but you must never write passwords in YAML. Instead store them as secrets (in repo settings) and reference them with ${{ secrets.NAME }}. They are encrypted and masked in logs:

      - name: Log in to the registry
        run: echo "${{ secrets.REGISTRY_PASSWORD }}" | \
             docker login 10.100.100.6 \
             -u "${{ secrets.REGISTRY_USER }}" --password-stdin

If you ever see a real password in a YAML file, that is a bug — flag it.

5. Build-and-push image workflow on the lab

This is the workflow you will actually build in Assignment 1. It builds a container image and pushes it to the lab's private registry at 10.100.100.6. The runner on 10.100.100.11 uses Docker-out-of-Docker (DooD) — meaning the job talks to the host's Docker daemon directly, so plain docker build works.

name: build-and-push

on:
  push:
    branches: [ main ]

jobs:
  image:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v4

      - name: Log in to the private registry
        run: echo "${{ secrets.REGISTRY_PASSWORD }}" | \
             docker login 10.100.100.6 \
             -u "${{ secrets.REGISTRY_USER }}" --password-stdin

      - name: Build the image
        run: docker build -t 10.100.100.6/myapp:${{ github.sha }} .

      - name: Push the image
        run: docker push 10.100.100.6/myapp:${{ github.sha }}

Two things to notice:

6. Expressions, variables, and the job lifecycle

A few more pieces you'll see:

jobs:
  test:
    runs-on: ubuntu-latest
    steps: [ { run: ./run-tests.sh } ]
  build:
    needs: test          # only runs if 'test' succeeded
    runs-on: ubuntu-latest
    steps: [ { run: docker build -t myapp . } ]

Each job starts on a clean machine, so anything one job produces and another needs (like a built file) must be passed explicitly — using artifacts (actions/upload-artifact / download-artifact) or, for images, by pushing to the registry. That registry image is the handoff point to the deploy step you'll build with Argo CD in the next chapters.

Dig deeper

Search terms

Check yourself

  1. Put these in order from largest to smallest: step, workflow, job. What does a runner do?
  2. Which key decides when a workflow runs, and how would you make it run only on pushes to main?
  3. What is the difference between a run: step and a uses: step?
  4. Why tag the image with ${{ github.sha }} instead of latest?
  5. How should you provide the registry password to the workflow, and why must it not appear in the YAML?

Lesson: Bitbucket Pipelines & Jenkins

What you'll learn

By the end you can compare three CI engines and pick a sensible one for a given situation.

The lesson

GitHub Actions is not the only CI/CD engine, and in your career you will meet others. The good news: the concepts from the previous chapters — triggers, stages, steps, runners, artifacts — apply everywhere. Only the syntax and the operating model change. This chapter gives you a working mental model of two common alternatives: Bitbucket Pipelines and Jenkins. We keep these at overview depth — enough to read one and know when to choose it.

1. The big picture: hosted vs self-managed

CI engines fall on a spectrum:

  More managed for you  <--------------------------------->  More you manage
  +-------------------+    +----------------------+    +-------------------+
  | GitHub Actions /  |    | Self-hosted runners  |    |     Jenkins       |
  | Bitbucket Pipelines|   | (Gitea runner, etc.) |    | (you run the      |
  | cloud runners      |   |                      |    |  whole server)    |
  +-------------------+    +----------------------+    +-------------------+

GitHub Actions and Bitbucket Pipelines are cloud-hosted: the vendor gives you runners and you just write YAML. Jenkins is a server you install and operate yourself — maximum control, maximum responsibility. The lab's Gitea runner sits in the middle: cloud-style YAML, but on a machine you own (10.100.100.11).

2. Bitbucket Pipelines

Bitbucket is Atlassian's Git hosting (often used alongside Jira). Bitbucket Pipelines is its built-in CI/CD, configured in a single file at the repo root: bitbucket-pipelines.yml.

The model is similar to GitHub Actions but simpler and more opinionated. Every step runs inside a Docker container (you pick the image), and steps are grouped under triggers like default, branches, and pull-requests:

image: node:20            # default container for all steps

pipelines:
  default:                # runs on every push (any branch)
    - step:
        name: Build and test
        script:
          - npm install
          - npm test
  branches:
    main:                 # runs only on the main branch
      - step:
          name: Build image
          script:
            - docker build -t myapp .
        services:
          - docker        # opt in to a Docker daemon

Notice the shape is the same idea as GitHub Actions: triggers at the top, then steps with a script: (the equivalent of run:). Key differences:

When to use it: your team already lives in Bitbucket/Jira. Limitations vs GitHub Actions: a far smaller marketplace of reusable actions, cloud build minutes are metered and can get costly, and it's less flexible for complex multi-job graphs.

3. Jenkins: the server/agent model

Jenkins is the veteran open-source automation server. Unlike the others, it is not bundled with a Git host — you install and run it yourself. Its defining feature is the controller/agent (historically "master/agent") architecture:

            +---------------------+
            |  Jenkins controller |   <- schedules jobs, stores config,
            |  (the web UI/brain) |      shows the dashboard
            +----------+----------+
                       |
        +--------------+--------------+
        |              |              |
   +---------+    +---------+    +---------+
   | Agent 1 |    | Agent 2 |    | Agent 3 |   <- where builds actually run
   | (Linux) |    | (Win)   |    | (ARM)   |
   +---------+    +---------+    +---------+

The controller is the brain: it stores configuration, schedules work, and serves the dashboard. Agents are worker machines that actually execute builds — you can have agents on different operating systems and architectures. This is powerful: one Jenkins can drive builds across a whole fleet.

Modern Jenkins pipelines are defined in code in a Jenkinsfile committed to your repo, written in a Groovy-based syntax:

pipeline {
    agent any                 // run on any available agent
    stages {
        stage('Build') {
            steps { sh 'docker build -t myapp .' }
        }
        stage('Test') {
            steps { sh './run-tests.sh' }
        }
        stage('Push') {
            steps { sh 'docker push myapp' }
        }
    }
}

Same concepts again — stages and steps — just different syntax (sh instead of run:/script:).

When to use it: you need to self-host for compliance or air-gapped reasons, you have unusual build needs (exotic OSes, hardware), or you have a huge existing Jenkins estate. Its enormous plugin ecosystem (1,800+ plugins) can integrate with almost anything.

Limitations / trade-offs vs GitHub Actions: you own the uptime, upgrades, security patching, and backups of the whole server — that's real operational work. The plugin ecosystem is powerful but plugins vary in quality and can break on upgrades. Groovy pipeline syntax is more complex than YAML. It does not come pre-integrated with your Git host's UI the way Actions does.

4. Choosing between them

A quick decision guide:

 Already on GitHub?            -> GitHub Actions
 Already on Bitbucket/Jira?    -> Bitbucket Pipelines
 Need full self-host control,
   exotic platforms, or have
   a big existing install?     -> Jenkins
 Want Actions syntax but on
   your own infrastructure?    -> self-hosted runner (the lab's Gitea runner)

The honest truth: the concepts dominate. Triggers, stages, runners/agents, secrets, artifacts, and push-vs-pull deployment exist in all three. If you deeply understand the GitHub Actions chapter, you can read a bitbucket-pipelines.yml or a Jenkinsfile and roughly follow it on day one. Pick the tool that fits where your code already lives and how much you want to operate yourself.

5. What they share (and what you should standardize on)

Whatever the engine, good pipelines look the same:

That last point — pipeline-as-code — is the throughline, and it sets up the next chapter, where Git becomes the source of truth for deployment too.

Dig deeper

Search terms

Check yourself

  1. What single file configures Bitbucket Pipelines, and what does every step always run inside?
  2. In Jenkins, what is the difference between the controller and an agent?
  3. Name one situation where Jenkins is the better choice than GitHub Actions, and one cost of choosing it.
  4. List two things that are true of a good pipeline regardless of which engine runs it.
  5. Why does the chapter say "the concepts dominate"? Give an example of a concept that appears in all three tools.

Lesson: Pull-Based Deployment with Argo CD

What you'll learn

By the end you can explain GitOps to a teammate and define an Argo CD Application that deploys to the lab cluster.

The lesson

In the concepts chapter you met pull deployment: instead of the CI pipeline pushing changes into production, a tool inside the environment watches Git and pulls changes in. GitOps is that idea made into a discipline, and Argo CD is the most popular tool for doing it on Kubernetes. This chapter shows how it works and has you deploy to the lab's real cluster (master 10.100.100.7).

1. The GitOps principle

GitOps rests on one rule: the desired state of your system is described declaratively in Git, and an automated agent continuously makes the cluster match Git.

"Declaratively" means you describe what you want (e.g. "3 replicas of myapp version abc123"), not how to get there. Kubernetes manifests (YAML) are already declarative, which is why GitOps and Kubernetes fit so well.

The four GitOps principles, in plain words:

  1. Declarative — the whole system is described as data, not scripts.
  2. Versioned in Git — that description lives in Git, so it has history, review, and rollback.
  3. Pulled automatically — an agent applies the Git state; no human runs kubectl apply.
  4. Continuously reconciled — the agent constantly checks that reality matches Git and corrects any difference.

The practical payoff: Git is the single source of truth. Want to know what's running? Read the repo. Want to roll back? git revert. Want an audit trail? It's the commit history.

2. Push CI vs pull GitOps

Compare the two models for the deploy step:

  PUSH (classic CI deploy):
  +-----------+   builds image   +-----------+   kubectl apply   +-----------+
  | CI runner |----------------> | registry  |                   |  cluster  |
  | (Gitea,   |                  |10.100.100.6|<-- CI also reaches|10.100.100.7|
  |  .11)     |---- has cluster credentials --- and pushes ----->|           |
  +-----------+                  +-----------+                   +-----------+
       The CI runner needs production credentials. It initiates the change.

  PULL (GitOps with Argo CD):
  +-----------+   builds image   +-----------+
  | CI runner |----------------> | registry  |
  | (.11)     |                  |10.100.100.6|
  +-----+-----+                  +-----------+
        | updates a manifest in Git (image tag = new sha)
        v
  +-----------+    Argo CD watches & pulls    +-----------+
  | Git repo  |<------------------------------|  Argo CD   |
  | (manifests)|                              | (in cluster|
  +-----------+                               |  .7)       |
                                              +-----------+
       CI only touches Git. Argo CD, inside the cluster, pulls and applies.

The crucial difference: in the pull model, production credentials never leave the cluster. Your CI runner only needs write access to a Git repo and the image registry — not to the cluster. That shrinks the blast radius if your CI is ever compromised, and it means every change to production is a Git commit you can see and revert.

3. The Argo CD Application

Argo CD runs inside your Kubernetes cluster. You tell it what to manage by creating an Application — itself a Kubernetes resource. An Application answers three questions: where is the desired state (source)?, where should it go (destination)?, and how should it sync?

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: myapp
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://gitea.example.com/intern/myapp-deploy.git
    targetRevision: main        # which branch/tag to track
    path: k8s                   # folder of manifests within the repo
  destination:
    server: https://kubernetes.default.svc   # the cluster Argo CD lives in
    namespace: myapp
  syncPolicy:
    automated:
      prune: true               # delete resources removed from Git
      selfHeal: true            # undo manual changes that drift from Git

Read it as a sentence: "Watch the k8s/ folder on the main branch of this repo; keep namespace myapp in this cluster matching it; do it automatically."

4. Sync, drift, and self-heal

Two concepts make Argo CD powerful:

  Git says: replicas=3        Live cluster: replicas=5  (someone edited it)
                |                          |
                +-----> Argo CD compares <-+
                            |
                  status = OutOfSync, diff shown
                            |
                 selfHeal -> sets replicas back to 3

prune: true extends this to deletions: if you remove a resource from Git, Argo CD removes it from the cluster too. This is what "Git is the source of truth" really means in practice — the cluster has no state that isn't in Git.

5. How a deploy actually flows in the lab

Putting the whole module together, here's the end-to-end flow you'll build across the assignments:

  1. You push app code to Gitea. The Actions runner on 10.100.100.11 builds a container image tagged with the commit SHA and pushes it to the registry at 10.100.100.6 (Module chapter 2).
  2. The image tag in your deployment manifest (in a Git repo Argo CD watches) is updated to the new SHA — either by hand at first, or automated later.
  3. Argo CD, running in the cluster (10.100.100.7), sees the manifest changed, marks the Application OutOfSync, and syncs — pulling the new image and rolling out the update.
  4. If anyone manually messes with the running app, drift detection flags it and selfHeal puts it back.

You never run kubectl apply against production yourself. You commit to Git; Argo CD does the rest.

6. When push is still fine

GitOps isn't mandatory for everything. For a tiny project, a push-based kubectl apply step in your CI is simpler and perfectly acceptable. GitOps shines when you have multiple environments or clusters, want strong audit/rollback, and want to keep cluster credentials out of CI. As the number of things you deploy grows, pull-based GitOps scales better and is safer.

Dig deeper

Search terms

Check yourself

  1. State the one rule that GitOps rests on.
  2. In the pull model, why don't your CI runner need credentials to the cluster?
  3. What three questions does an Argo CD Application answer?
  4. What does it mean for an Application to be OutOfSync, and what does selfHeal: true do about it?
  5. Walk through, in order, what happens in the lab from "you push app code" to "the new version is running" without you ever typing kubectl apply.

Assignment 1: Build-and-push pipeline on the lab runner

Goal: Write a GitHub-Actions-style workflow that builds a container image from your app and pushes it to the lab's private registry, running for real on the lab's Gitea Actions runner.

Where: A repository on the lab's Gitea server. The workflow executes on the Actions runner on the build VM 10.100.100.11 (Docker-out-of-Docker) and pushes to the private registry at 10.100.100.6.

Tasks

  1. Create (or reuse) a small app repo in Gitea with a working Dockerfile at its root. Keep the app trivial — a "hello" web server is plenty. Confirm docker build . works locally first.
  2. In the repo, create the workflow file .gitea/workflows/build-and-push.yml.
  3. Make the workflow trigger on: push to the main branch.
  4. Add a job with runs-on: ubuntu-latest and these steps, in order:
    • Check out the code with actions/checkout@v4.
    • Log in to the registry 10.100.100.6 using docker login with credentials read from secrets (--password-stdin). Do not type the password in the file.
    • Build the image, tagging it 10.100.100.6/<yourname>-app:${{ github.sha }}.
    • Push that image to the registry.
  5. In the repo settings, add the registry credentials as Actions secrets (e.g. REGISTRY_USER, REGISTRY_PASSWORD). Use the values your mentor provides; record real secret values nowhere in the repo.
  6. Push a commit to main and watch the workflow run in the Gitea Actions tab. Read the logs.
  7. If it fails, fix it and push again until it goes green. (This is normal — read the error, adjust, repeat.)
  8. Verify the image actually landed in the registry (ask your mentor for the registry catalog URL or use docker pull of your SHA-tagged image from a lab host).

Deliverable

Acceptance criteria — you're done when:

Hints

Assignment 2: GitOps-deploy your app with Argo CD

Goal: Use Argo CD to deploy the image you built in Assignment 1 onto the lab's live Kubernetes cluster, the pull-based GitOps way — without ever running kubectl apply against production yourself.

Where: A Git repo on the lab's Gitea server holds your Kubernetes manifests. Argo CD runs inside the lab cluster (master 10.100.100.7) and pulls from that repo. Your image lives in the registry at 10.100.100.6.

Tasks

  1. Create a new Gitea repo (e.g. <yourname>-deploy) to hold deployment manifests. This is your GitOps source of truth — keep it separate from your app code.
  2. Add a k8s/ folder with Kubernetes manifests for your app: at minimum a Deployment and a Service. Point the Deployment's container image: at your SHA-tagged image from Assignment 1: 10.100.100.6/<yourname>-app:<sha>.
  3. Commit and push the manifests to main.
  4. Create an Argo CD Application (a YAML manifest, as in chapter 4 section 3) that:
    • sets source.repoURL to your deploy repo, targetRevision: main, path: k8s;
    • sets destination to the in-cluster server and a namespace named after you;
    • enables syncPolicy.automated with prune: true and selfHeal: true.
  5. Apply the Application to Argo CD (your mentor will tell you how to reach Argo CD — its UI or argocd app create). Confirm the Application appears and reaches Synced and Healthy.
  6. Verify your app is actually running in the cluster (check the pods are Running and the Service responds).
  7. Demonstrate drift detection: manually change the live Deployment (e.g. scale replicas with kubectl scale). Watch Argo CD report OutOfSync and then selfHeal revert it. Capture what you observed.
  8. Demonstrate a GitOps update: change the image tag in your manifest to a newer SHA (rebuild via Assignment 1 if needed), commit, and watch Argo CD sync the new version automatically.

Deliverable

Acceptance criteria — you're done when:

Hints