Build a Local CI/CD Pipeline
Use Jenkins, Docker, and Kubernetes to deploy a Node.js service from GitHub.
Introduction
30 Second Summary
A change that works on your laptop can still fail when it reaches another machine. Repeating the path from saved code to a running app by hand makes each release fragile.
In this project, you will build a local CI/CD pipeline that carries one JSON web service from GitHub to Kubernetes. Jenkins tests the committed code before Docker packages it for deployment.
What You'll Build
You'll see one GitHub commit emerge from a green Jenkins run as a JSON message served from your local Kubernetes cluster.
By the end of this project, you'll have:
- A browser-visible JSON service that returns {"message":"Hello from GitHub through Jenkins, Docker, and Kubernetes!"} from your machine.
- A Pipeline as Code workflow where Jenkins shows each delivery stage turning green.
- A repaired Kubernetes Deployment that you can diagnose after an ImagePullBackOff failure.
- Secret Mission: Scale the same Deployment from one Pod to three replicas. Verify that every replica becomes ready.
Are there any prerequisites?
You'll need a Mac with Git. Docker Desktop should already be installed.
Your GitHub account should also be ready.
Before We Start
This opening checkpoint turns your delivery path into a clear commitment before any setup begins. Your long-lived hello-cicd repository will let each GitHub commit flow through Jenkins into a Docker image that runs on Docker Desktop Kubernetes.
Set Up Jenkins and Local Kubernetes
A local Jenkins controller needs access to Docker Desktop's image store. It also needs the configuration for your Kubernetes cluster.
Running Jenkins under your macOS user places those local resources in the same environment. This keeps the pipeline focused on delivery instead of host credential wiring.
In this step, get ready to:
- Install Jenkins LTS with Homebrew.
- Configure the Jenkins Dashboard with an administrator account.
- Bring a local kubeadm node to Ready status.
Install Jenkins LTS with Homebrew
macOS Terminal gives you one place to install Jenkins and keep its console output visible. Homebrew handles the Jenkins LTS package for your Mac.
Why run Jenkins under your macOS user?
Jenkins launched from your account can reach the local Docker command paths. The same account owns the Kubernetes configuration used later in the pipeline.
- Press Cmd+Space to open macOS search.
- Type Terminal into the search field.
- Press Enter to open Terminal.
- Check whether Homebrew is available by running this command:
brew --version
What does this command do?
The brew --version command asks Homebrew to print its installed version. A version response confirms that its command is available in this Terminal session.
✔️ I see a Homebrew version
Good. Homebrew is ready to install Jenkins LTS.
ⓧ Command not found
Homebrew needs to be installed before it can fetch Jenkins LTS. Its installer prints a shell setup instruction tailored to your Mac.
- Install Homebrew by running this command:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
What does this command do?
This downloads Homebrew's official installation script. The script installs Homebrew into the standard location for your Mac's processor.
Homebrew installation stopped?
Check the Terminal output for a password prompt or a permission request. Complete that prompt before the installer can continue.
Ask for help with the exact output you see: Help me troubleshoot my Homebrew installation on macOS.
- Follow the shell setup instruction printed at the end of the installation.
- Confirm Homebrew is available by running this command:
brew --version
What does this command confirm?
A version response confirms that the shell setup made Homebrew available in this Terminal session. You can now use Homebrew to install Jenkins LTS.
You should now see Homebrew version information in Terminal.
- Install Jenkins LTS by running this command:
brew install jenkins-lts
What does this command do?
Homebrew downloads the jenkins-lts formula with its required runtime. The LTS package gives you the supported Jenkins release track used throughout this project.
You should see Homebrew finish with Jenkins LTS installed. Your Mac now has the controller that will run each pipeline stage.
Jenkins failed to install?
Check that the Homebrew installation completed successfully before retrying the Jenkins command. A network interruption can also stop the package download.
Help me diagnose the Jenkins LTS installation output.
Start Jenkins and complete its setup
Jenkins runs in the foreground so its startup log remains visible. That log contains the generated administrator password used by the setup wizard.
- Start Jenkins in the current Terminal tab by running this command:
jenkins-lts
What does this command do?
The jenkins-lts launcher starts the Jenkins controller on its default local port. The command keeps running because this Terminal tab now hosts the Jenkins process.
The startup output includes a generated administrator password between two lines of asterisks. Keep that password private because it unlocks the initial setup wizard.
Jenkins not starting?
Keep Docker Desktop open while checking whether another local application already uses the Jenkins port. Review the first error in the Terminal output because later lines may only describe the shutdown.
Help me diagnose why Jenkins LTS is not starting.
- Copy the generated administrator password from the console output.
- Leave this Terminal tab running.
- Open http://localhost:8080 in your browser.
- Complete the Unlock Jenkins screen with the generated password.
Plugin installation can take a few minutes while Jenkins prepares the features used by this project. Keep the browser open while the progress screen is active.
- Select Install suggested plugins.
- Complete the Create First Admin User page with administrator details you can store safely.
- Select Save and Finish.
- Select Start using Jenkins.
You should see the Jenkins Dashboard in your browser. The controller is now running under your macOS user.
Setup wizard not completing?
Confirm that the Terminal tab running Jenkins remains open. If plugin installation stalls, check that your Mac still has an active internet connection.
Help me finish the Jenkins setup wizard.
Create and verify the local Kubernetes cluster
Docker Desktop's kubeadm provisioner creates the local cluster used by the pipeline. Its Kubernetes view requires Docker Desktop 4.51 or later.
Why kubeadm for this project?
The kubeadm provisioner works with Docker Desktop's local image store. This matters because Jenkins will build the application image locally without pushing it to a registry.
The kind provisioner does not work with the Docker image store used by this workflow. Kubeadm preserves the shortest path from a Jenkins build to a local deployment.
- Press Cmd+Space to open macOS search.
- Type Docker Desktop into the search field.
- Press Enter to bring Docker Desktop forward.
- Select the Docker whale icon in the macOS menu bar.
- Select About Docker Desktop to check the installed version.
✔️ I have the required version
Your installed version is 4.51 or later. Docker Desktop includes the Kubernetes view used below.
- Close the About Docker Desktop window.
ⓧ I have an older version
An older Docker Desktop release may use a different Kubernetes setup flow. Update the existing installation before creating the cluster.
- Follow Docker's official Mac installation guide to install the current release over your existing copy.
- Start Docker Desktop through macOS search after the update finishes.
- Confirm that About Docker Desktop shows version 4.51 or later.
ⓧ Docker Desktop is not installed
Docker Desktop provides the local image store and Kubernetes cluster required by this pipeline. Install the current Mac release before continuing.
- Follow Docker's official Mac installation guide to install Docker Desktop.
- Start Docker Desktop through macOS search after installation.
- Confirm that About Docker Desktop shows version 4.51 or later.
- Select Kubernetes in the Docker Desktop navigation.
- Select Create cluster.
- Choose kubeadm as the cluster type.
Cluster creation takes about one minute while Docker Desktop provisions the single node. A quiet progress screen during that time is expected.
- Select Create.
- Wait for the Kubernetes view to show that the cluster is running.
- Return to the Terminal window from earlier.
- Press Cmd+T to create a second Terminal tab.
- Point kubectl at the new local cluster by running this command:
kubectl config use-context docker-desktop
What does this command do?
The command selects docker-desktop as the active Kubernetes context. Future kubectl commands now target the cluster you created in Docker Desktop.
You should see a confirmation that the active context is now docker-desktop.
Context not available?
Return to Docker Desktop and confirm that cluster creation has finished. The context becomes available after Docker Desktop writes the local Kubernetes configuration.
Help me connect kubectl to Docker Desktop Kubernetes.
Before you run the node check, do you expect the new cluster to report that its single node is ready?
- Check the cluster node by running this command:
kubectl get nodes
What does this command confirm?
The command asks the active cluster for its nodes and their current status. A Ready node can accept the Kubernetes workload you deploy later.
You should see a node named docker-desktop with Ready status.
Node not Ready?
Give Docker Desktop another moment to finish provisioning the cluster. Confirm that the Kubernetes view still shows the cluster running before repeating the node check.
Help me diagnose my Docker Desktop Kubernetes node status.
- Return to the browser tab at http://localhost:8080.
- Refresh the Jenkins page.
You should still see the Jenkins Dashboard while the Terminal shows the docker-desktop node as Ready.
That is the local foundation complete. Jenkins can now reach the same Docker Desktop environment that hosts your Kubernetes cluster.
Your local CI/CD foundation is ready. Next up, you will create a tested Node.js service that this pipeline can move from source code to a running application.
Create and Publish the Node.js Service
Your local delivery foundation is ready. Jenkins can run jobs while Kubernetes waits for a workload.
The pipeline now needs a real application to move. You will build a zero-dependency Node.js service that returns a visible response. A pinned Docker image will run its test. GitHub will become the source of truth for all three files.
In this step, get ready to:
- Create the Node.js service with one automated test.
- Run the test and view the service response through Docker.
- Publish the tested source files to a public GitHub repository.
Create the service and test files
The service separates its response from its HTTP server. This keeps the message logic small enough to test without starting a network process.
- Switch back to the Terminal tab you used for Kubernetes.
- Create the hello-cicd folder on your Desktop by running these commands:
cd ~/Desktop
mkdir hello-cicd
ls
cd hello-cicd
What do these commands do?
- The first command moves the Terminal session to your Desktop.
- The second command creates the hello-cicd folder.
- The third command lists the Desktop contents so you can confirm the folder exists.
- The final command moves the Terminal session into hello-cicd.
You should see hello-cicd in the output before the Terminal moves into that folder.
- Start writing message.mjs from Terminal by running this command:
cat > message.mjs
What does this command do?
The cat command now waits for text from your keyboard. The redirect writes that text into message.mjs.
- Paste this code into the waiting Terminal:
const MESSAGE = 'Hello from GitHub through Jenkins, Docker, and Kubernetes!';
export function buildMessage() {
return { message: MESSAGE };
}
What does this code do?
- The MESSAGE constant holds the text that travels through the pipeline.
- The buildMessage() function returns that text inside a JSON object.
- Press Control-D to finish writing message.mjs.
- Confirm that message.mjs exists by running:
ls message.mjs
What should I see?
You should see message.mjs printed on its own line. That confirms the first source file is on disk.
Missing message.mjs?
Make sure you pressed Control-D after pasting the code. If the prompt still waits for input, press the shortcut once more.
Ask for help checking the file creation command.
- Start writing app.mjs from the same hello-cicd folder by running:
cat > app.mjs
What does this command do?
This command directs the next text you paste into app.mjs. The file sits beside message.mjs so its relative import can resolve.
- Paste the HTTP server code into the waiting Terminal:
import { createServer } from 'node:http';
import { buildMessage } from './message.mjs';
const port = 3000;
const server = createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(buildMessage()));
});
server.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
How does the server work?
- The imports load Node.js HTTP support plus the buildMessage() function from message.mjs.
- The port constant sends browser traffic to port 3000.
- The request handler responds with a successful status plus JSON content.
- The final block starts the server and prints its listening port.
- Press Control-D to finish writing app.mjs.
- Confirm that app.mjs exists by running:
ls app.mjs
What should I see?
You should see app.mjs printed in the Terminal. The server entry point is now saved beside its message module.
Missing app.mjs?
Check that the Terminal prompt returned after you pressed Control-D. Confirm that the filename uses the .mjs extension.
Ask for help finding app.mjs in the project folder.
- Start writing message.test.mjs from the same folder by running:
cat > message.test.mjs
What does this command do?
This command prepares message.test.mjs for the test code you paste next. Keeping the test beside the source lets the Node.js test runner discover it.
- Paste the automated test into the waiting Terminal:
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildMessage } from './message.mjs';
test('buildMessage returns the pipeline message', () => {
assert.deepStrictEqual(buildMessage(), {
message: 'Hello from GitHub through Jenkins, Docker, and Kubernetes!',
});
});
How does the test work?
- The imports load strict assertions plus the built-in Node.js test runner.
- The test calls buildMessage() with no server running.
- The assertion checks the complete response object. A changed message makes the test fail.
- Press Control-D to finish writing message.test.mjs.
- Confirm that message.test.mjs exists by running:
ls message.test.mjs
What should I see?
You should see message.test.mjs in the output. All three project files are now ready for a test run.
Missing the test file?
Check the full filename carefully. It must be message.test.mjs so the test command can discover it.
Ask for help checking the test filename.
✔️ Awesome, I've got everything!
Great. Your hello-cicd folder now contains the service module, server entry point, and automated test.
ⓧ I'd like to double check the full code
Compare each file with the complete versions below. Every character should match before you run the test.
const MESSAGE = 'Hello from GitHub through Jenkins, Docker, and Kubernetes!';
export function buildMessage() {
return { message: MESSAGE };
}
import { createServer } from 'node:http';
import { buildMessage } from './message.mjs';
const port = 3000;
const server = createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(buildMessage()));
});
server.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildMessage } from './message.mjs';
test('buildMessage returns the pipeline message', () => {
assert.deepStrictEqual(buildMessage(), {
message: 'Hello from GitHub through Jenkins, Docker, and Kubernetes!',
});
});
Before you run the test, how many passing tests do you expect to see? Keep that number in mind.
- Run the test inside the pinned Node.js image with this command:
docker run --rm --volume "$PWD:/app" --workdir /app node:24.20.0-alpine3.24 node --test
What does this command do?
- The pinned node:24.20.0-alpine3.24 image supplies the exact Node.js runtime.
- The volume mounts the current hello-cicd folder at /app inside the container.
- The working directory makes the test runner execute against those mounted files.
- The --rm option removes the temporary container after the test finishes.
You should see one passing test with no failures. That result proves the message logic matches the expected response.
Test did not pass?
Compare the message in message.mjs with the expected message in message.test.mjs. Check every capital letter plus punctuation mark.
Ask for help debugging the Node.js test.
Run the service in Docker
A passing unit test proves the message function works. Running the HTTP server proves a browser can reach the same response through port 3000.
Before you start the service, what response do you expect the browser to receive? Picture the message from the passing test.
- Start the service through the same pinned image by running:
docker run --rm --publish 3000:3000 --volume "$PWD:/app" --workdir /app node:24.20.0-alpine3.24 node app.mjs
How does the service reach your browser?
- The source folder is mounted into /app so the container runs your current files.
- Port 3000 in the container is published to port 3000 on your Mac.
- The final arguments start app.mjs with Node.js.
The Terminal stays attached to the server while it runs. Leave this command active during the browser check.
- Return to your browser from earlier.
- Enter http://localhost:3000 in the address bar.
You should see {"message":"Hello from GitHub through Jenkins, Docker, and Kubernetes!"} in the browser. The service is running entirely from the pinned container image.
Browser cannot reach the service?
Return to Terminal and confirm the server command is still running. Check that the command publishes port 3000 and that your browser uses the same port.
Ask for help tracing the local Docker connection.
That is your first visible delivery win. The pinned image can now test and serve the same source code.
- Return to the Terminal running the service.
- Press Control-C to stop the container.
Publish the repository to GitHub
A Git repository records the tested source as a commit. Publishing that commit gives Jenkins a stable remote source in the next step.
- Return to the browser where you are signed in to GitHub.
- Select the plus menu in the upper-right corner.
- Click New repository.
- Select your personal account from the Owner dropdown.
- Enter hello-cicd in the Repository name field.
- Select Public for the repository visibility.
The three source files already exist on your Mac. Keeping the remote repository empty prevents an unrelated starter commit from entering its history.
- Leave Add README unselected.
- Leave the .gitignore option unchanged.
- Leave the license option unchanged.
- Click Create repository.
You should see an empty public repository named hello-cicd with command-line setup instructions.
GitHub may ask you to confirm your identity during the first push. The repository only receives the three source files in your hello-cicd folder.
- Replace YOUR-GITHUB-USERNAME in the remote URL with your GitHub username.
- Initialize the repository and publish its first commit by running these commands:
git init -b main
git add .
git commit -m "Create tested Node service"
git remote add origin https://github.com/YOUR-GITHUB-USERNAME/hello-cicd.git
git push -u origin main
What do these Git commands do?
- The first command creates a local repository with main as its initial branch.
- The next two commands stage the three files and record them in the Create tested Node service commit.
- The remote command connects the local repository to GitHub under the name origin.
- The final command publishes main and connects it to the remote branch.
Push did not complete?
Check that you replaced YOUR-GITHUB-USERNAME with the username shown on your GitHub profile. Confirm that the remote repository is named hello-cicd.
Ask for help with the first GitHub push.
Before you refresh the repository page, which branch and files do you expect to see? Hold that picture for the final check.
- Return to the browser tab showing the hello-cicd repository.
- Refresh the repository page.
You should see main as the default branch. The file list should contain app.mjs, message.mjs, and message.test.mjs.
Your tested service is now published. Jenkins has a clean remote source to check out and run.
Your application now works locally and lives in GitHub. Next up, you will add a Jenkinsfile so every pipeline run executes the same passing test.
Run Your First Jenkins Pipeline
Your tested service now lives in GitHub. Jenkins still needs a repeatable instruction for checking out that code and running its test.
A committed Jenkinsfile turns the test into Pipeline as Code. This makes the repository the source of truth for every run.
In this step, get ready to:
- Create a Jenkinsfile that defines a repeatable test stage.
- Publish the pipeline definition to main.
- Configure Jenkins to load the pipeline from your public repository.
Define the test pipeline
A Jenkins Declarative Pipeline describes each automated stage inside a version-controlled file. Your first stage runs the same test that already passed locally.
- Create a root-level Jenkinsfile in the local hello-cicd repository by copying this code into a new file:
pipeline {
agent any
options {
disableConcurrentBuilds()
}
stages {
stage('Test') {
steps {
withEnv([
'PATH+HOMEBREW=/opt/homebrew/bin',
'PATH+USRLOCAL=/usr/local/bin',
'PATH+DOCKER=/Applications/Docker.app/Contents/Resources/bin'
]) {
sh 'docker run --rm --volume "$WORKSPACE:/app" --workdir /app node:24.20.0-alpine3.24 node --test'
}
}
}
}
}
What does this pipeline do?
- The pipeline block defines the complete workflow. agent any lets Jenkins run it on the available local agent.
- The disableConcurrentBuilds() option stops two runs from competing for the same local environment.
- The withEnv block prepends command locations to $PATH. Jenkins can then find Homebrew and Docker Desktop tools under your macOS user.
- The Test stage launches the pinned Node.js image 24.20.0-alpine3.24. It runs the committed test suite inside that container.
- Save Jenkinsfile.
- Check the top level of the local hello-cicd repository in your file editor.
You should see Jenkinsfile directly beside app.mjs, message.mjs, and message.test.mjs.
Can't find Jenkinsfile at the top level?
Check that the file name is exactly Jenkinsfile with no hidden extension. Keep it beside app.mjs so Jenkins can find the default script path.
Help me check the location and contents of my Jenkinsfile.
✔️ Awesome, I've got everything!
Your Jenkinsfile now defines one Test stage using the pinned Node.js image.
ⓧ I'd like to double check the full code
- Compare your root-level Jenkinsfile with this complete version:
pipeline {
agent any
options {
disableConcurrentBuilds()
}
stages {
stage('Test') {
steps {
withEnv([
'PATH+HOMEBREW=/opt/homebrew/bin',
'PATH+USRLOCAL=/usr/local/bin',
'PATH+DOCKER=/Applications/Docker.app/Contents/Resources/bin'
]) {
sh 'docker run --rm --volume "$WORKSPACE:/app" --workdir /app node:24.20.0-alpine3.24 node --test'
}
}
}
}
}
Publish the pipeline definition
A Git commit gives the pipeline definition a permanent place in the repository history. Jenkins can only load the new file after it reaches the remote main branch.
- Publish Jenkinsfile from the local hello-cicd repository by running these commands in Terminal:
git add Jenkinsfile
git commit -m "Add Jenkins test pipeline"
git push origin main
What do these commands do?
- The first command stages only Jenkinsfile for the next commit.
- The second command records the pipeline definition with a descriptive message.
- The third command sends the new commit to main in the public repository.
- Return to the public hello-cicd repository in your browser from earlier.
- Confirm the file list on main includes Jenkinsfile.
Good progress. The repository now carries the test instructions that Jenkins retrieves for every build.
Didn't Jenkinsfile reach GitHub?
Check that the commit completed before the push started. Confirm that the local repository still points to the existing public hello-cicd repository.
Help me troubleshoot why my Jenkinsfile did not reach GitHub.
Configure Jenkins and run the test
A Pipeline item connects Jenkins to the repository that owns the automation instructions. Jenkins checks out main and reads the root-level Jenkinsfile whenever you start a build.
- Switch back to the Jenkins Dashboard at http://localhost:8080.
- Click New Item.
You'll see the form for creating a Jenkins item.
- Enter hello-cicd as the item name.
- Select Pipeline.
The form now identifies hello-cicd as a Pipeline item.
- Click OK.
You'll see the configuration page for hello-cicd.
- Set Definition to Pipeline script from SCM.
- Set SCM to Git.
You'll see the source fields that tell Jenkins where to retrieve the pipeline.
- Paste the public hello-cicd repository URL into Repository URL.
- Enter refs/heads/main in the branch field.
The source settings now identify the public repository's main branch.
- Keep Script Path set to Jenkinsfile.
- Click Save.
You'll return to the hello-cicd item page with the source configuration saved.
Before you start the build, do you think Jenkins can find the committed test without a test command configured in the dashboard?
- Start the checkout and test run by clicking Build Now.
When the run finishes, you'll see a green Test stage in Pipeline Overview.
- Open the Test stage log from Pipeline Overview.
You'll see one passing Node.js test in the log. This proves Jenkins checked out the committed repository and followed its pipeline definition.
That's your first repeatable pipeline run complete. Your repository can now trigger the same passing test through Jenkins.
Test stage not turning green?
- Confirm the public repository contains Jenkinsfile at its top level.
- Check that the configured branch is exactly refs/heads/main.
- Confirm Docker Desktop is running because the Test stage launches the pinned Node.js image.
Help me diagnose my failing Jenkins Test stage.
Your repository now drives a green test stage in Jenkins. Next, you'll package the same source as a Docker image and add it to the pipeline.
Build the Image and Diagnose the Pull Failure
Your green Test stage proved that Jenkins can turn committed code into a repeatable check. Now you can extend that delivery path beyond testing.
A locally built Docker image lives on your Mac. Kubernetes still needs a valid way to acquire that image for a Pod. This step tests that boundary through the pipeline.
In this step, get ready to:
- Package the tested service as a local Docker image.
- Describe the application with a Kubernetes Deployment plus a Service.
- Run the expanded pipeline to diagnose the rollout failure.
Package the service in a Docker image
A Docker image bundles your source files with the runtime that executes them. The same package can run tests before Kubernetes attempts to deploy it.
- Return to the code editor you used earlier for the hello-cicd project.
- Create a file named Dockerfile inside the hello-cicd folder.
- Paste this image definition into Dockerfile:
FROM node:24.20.0-alpine3.24
WORKDIR /app
COPY app.mjs message.mjs message.test.mjs ./
EXPOSE 3000
CMD ["node", "app.mjs"]
What does this Dockerfile do?
- The pinned Node.js image supplies the runtime used throughout the project.
- The WORKDIR instruction makes /app the location for later instructions.
- The COPY instruction places the service plus its test inside the image.
- The final two instructions document port 3000 plus the command that starts the service.
- Save Dockerfile.
- Build the local image by running this command in the hello-cicd project directory:
docker build --tag hello-cicd:local .
What does this command prove?
Docker reads Dockerfile from the current directory. It stores the completed image under the hello-cicd:local tag.
- Test the code inside the completed image by running:
docker run --rm hello-cicd:local node --test
What should you see?
The container runs the test suite stored inside the image. You should see one passing test with no failures.
Image build or test failing?
Confirm that Dockerfile is inside the same hello-cicd folder as the three .mjs files.
Check that Docker Desktop is still running before you retry the command.
Help me diagnose my local image build or container test.
You have packaged the service successfully. The image now contains the same passing test that Jenkins already ran from the repository.
Define the Kubernetes workload
A Deployment maintains the requested number of application Pods. A Service gives those Pods one stable network entry point.
- Create a folder named k8s inside the hello-cicd folder using your editor's file controls.
- Confirm that the editor now lists the empty k8s folder.
- Create app.yaml inside the k8s folder.
- Confirm that the editor now lists k8s/app.yaml.
- Paste this Deployment definition into k8s/app.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello-cicd
spec:
replicas: 1
selector:
matchLabels:
app: hello-cicd
template:
metadata:
labels:
app: hello-cicd
spec:
containers:
- name: hello-cicd
image: hello-cicd:local
imagePullPolicy: Always
ports:
- containerPort: 3000
What does this Deployment request?
- The Deployment requests one Pod running the hello-cicd:local image.
- The app: hello-cicd label identifies the Pod managed by this Deployment.
- The imagePullPolicy: Always setting tells Kubernetes to retrieve the image whenever it starts the container.
- The container declares that the application listens on port 3000.
- Save k8s/app.yaml.
- Confirm that the file contains the hello-cicd Deployment with one replica.
Deployment YAML looking different?
Check the indentation beneath spec plus containers. YAML uses spaces to represent nesting.
Help me check my Deployment manifest indentation.
The Service uses the Pod label as its selector. Requests sent to Service port 3000 reach the same port on the selected Pod.
- Append this Service definition beneath the Deployment in k8s/app.yaml:
---
apiVersion: v1
kind: Service
metadata:
name: hello-cicd
spec:
selector:
app: hello-cicd
ports:
- port: 3000
targetPort: 3000
How does the Service find the Pod?
The Service selector matches the Pod label app: hello-cicd. Its targetPort forwards traffic to the application's listening port.
- Save k8s/app.yaml.
- Confirm that the file contains the --- separator above the Service.
- Confirm that both selectors use app: hello-cicd.
Service selector not matching?
Compare the Service selector with the label under the Deployment's Pod template. Both values must read app: hello-cicd.
Help me match my Service selector to my Deployment labels.
Extend the pipeline and inspect the failure
The pipeline needs one stage to build the local image. It needs another stage to apply the manifest plus verify the rollout.
- In Jenkinsfile locate the end of the existing Test stage inside stages.
- Paste this new stage immediately after the Test stage:
stage('Build Image') {
steps {
withEnv([
'PATH+HOMEBREW=/opt/homebrew/bin',
'PATH+USRLOCAL=/usr/local/bin',
'PATH+DOCKER=/Applications/Docker.app/Contents/Resources/bin'
]) {
sh 'docker build --tag hello-cicd:local .'
sh 'docker run --rm hello-cicd:local node --test'
}
}
}
What does the Build Image stage do?
The stage builds hello-cicd:local from the committed repository. It runs the image's test before deployment can begin.
The existing path entries let Jenkins find Docker on either Mac processor architecture.
- Save Jenkinsfile.
- Confirm that Build Image appears after Test inside stages.
Build stage outside the pipeline?
Make sure the new stage('Build Image') block sits inside the existing stages block. It must sit beside the Test stage.
Help me place the Build Image stage correctly.
The deployment stage sends the committed desired state to the cluster. Its final command waits up to 30s for the Deployment to become ready.
- Locate the end of the new Build Image stage.
- Paste this deployment stage immediately below it:
stage('Deploy') {
steps {
withEnv([
'PATH+HOMEBREW=/opt/homebrew/bin',
'PATH+USRLOCAL=/usr/local/bin',
'PATH+DOCKER=/Applications/Docker.app/Contents/Resources/bin'
]) {
sh 'kubectl config use-context docker-desktop'
sh 'kubectl apply -f k8s/'
sh 'kubectl rollout restart deployment/hello-cicd'
sh 'kubectl rollout status deployment/hello-cicd --timeout=30s'
}
}
}
What does the Deploy stage do?
- The first command selects the local docker-desktop cluster.
- The second command applies every manifest in k8s/.
- The third command restarts the hello-cicd Deployment.
- The final command makes rollout readiness part of the pipeline result.
- Save Jenkinsfile.
- Confirm that Test remains the first stage.
- Confirm that Build Image is the second stage.
- Confirm that Deploy is the third stage.
Jenkinsfile showing a syntax problem?
Check that each stage closes before the next stage begins. Keep both new stages inside the original stages block.
Help me find the brace mismatch in my Jenkinsfile.
Jenkins reads this pipeline from the repository. The new files must reach the main branch before another build can use them.
- Stage all project changes with the Git workflow you used earlier.
- Create a commit that describes the image build plus Kubernetes deployment.
- Push the commit to the repository's main branch.
Your repository now holds the application plus every delivery instruction. Jenkins can check out one commit to test it before attempting a deployment.
Before you start the build, do you think every stage can finish when the manifest requests a fresh copy of a local-only image?
- Switch back to Jenkins at http://localhost:8080.
- Select the hello-cicd Pipeline item.
- Select Build Now.
Give the Deploy stage its full 30-second timeout. That wait is the pipeline checking whether Kubernetes can make the Pod ready.
You will see Test pass. Build Image also passes. Deploy ends in failure when rollout verification reaches its timeout.
Before you inspect the cluster, what Pod status do you expect after Kubernetes tries to retrieve the local-only image?
- Inspect the Pod status from the second Terminal tab by running:
kubectl get pods
What does the Pod status reveal?
You should see the hello-cicd Pod report ImagePullBackOff. Kubernetes tried to acquire hello-cicd:local according to the pull policy.
The image exists in the local Docker image store. The manifest currently demands an image pull. That boundary caused the planned pipeline failure.
Seeing a different Pod status?
Wait a few seconds before running the command again. Kubernetes can show an earlier startup status while repeated image pulls are still failing.
Confirm that the committed manifest contains imagePullPolicy: Always plus the image name hello-cicd:local.
Help me diagnose my Kubernetes Pod status.
✔️ Awesome, I've got everything!
Your local image passes its test. Your repository now contains the image definition plus the Kubernetes manifest. Your expanded pipeline reaches the planned pull failure.
ⓧ I'd like to double check the full code
Compare your Dockerfile with this reference.
FROM node:24.20.0-alpine3.24
WORKDIR /app
COPY app.mjs message.mjs message.test.mjs ./
EXPOSE 3000
CMD ["node", "app.mjs"]
Dockerfile checkpoint
This file builds the pinned Node.js image with the application plus its test. It starts the service on port 3000.
Compare your Jenkinsfile with this reference.
pipeline {
agent any
options {
disableConcurrentBuilds()
}
stages {
stage('Test') {
steps {
withEnv([
'PATH+HOMEBREW=/opt/homebrew/bin',
'PATH+USRLOCAL=/usr/local/bin',
'PATH+DOCKER=/Applications/Docker.app/Contents/Resources/bin'
]) {
sh 'docker run --rm --volume "$WORKSPACE:/app" --workdir /app node:24.20.0-alpine3.24 node --test'
}
}
}
stage('Build Image') {
steps {
withEnv([
'PATH+HOMEBREW=/opt/homebrew/bin',
'PATH+USRLOCAL=/usr/local/bin',
'PATH+DOCKER=/Applications/Docker.app/Contents/Resources/bin'
]) {
sh 'docker build --tag hello-cicd:local .'
sh 'docker run --rm hello-cicd:local node --test'
}
}
}
stage('Deploy') {
steps {
withEnv([
'PATH+HOMEBREW=/opt/homebrew/bin',
'PATH+USRLOCAL=/usr/local/bin',
'PATH+DOCKER=/Applications/Docker.app/Contents/Resources/bin'
]) {
sh 'kubectl config use-context docker-desktop'
sh 'kubectl apply -f k8s/'
sh 'kubectl rollout restart deployment/hello-cicd'
sh 'kubectl rollout status deployment/hello-cicd --timeout=30s'
}
}
}
}
}
Pipeline checkpoint
This file preserves the original Test stage. It adds the image build plus the Kubernetes deployment workflow.
Compare your app.mjs with this unchanged reference.
import { createServer } from 'node:http';
import { buildMessage } from './message.mjs';
const port = 3000;
const server = createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(buildMessage()));
});
server.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
Application checkpoint
This file still serves the message as JSON on port 3000.
Compare your k8s/app.yaml with this reference.
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello-cicd
spec:
replicas: 1
selector:
matchLabels:
app: hello-cicd
template:
metadata:
labels:
app: hello-cicd
spec:
containers:
- name: hello-cicd
image: hello-cicd:local
imagePullPolicy: Always
ports:
- containerPort: 3000
---
apiVersion: v1
kind: Service
metadata:
name: hello-cicd
spec:
selector:
app: hello-cicd
ports:
- port: 3000
targetPort: 3000
Kubernetes checkpoint
This manifest requests one application Pod. Its temporary pull policy produces the failure you diagnosed.
Compare your message.mjs with this unchanged reference.
const MESSAGE = 'Hello from GitHub through Jenkins, Docker, and Kubernetes!';
export function buildMessage() {
return { message: MESSAGE };
}
Message checkpoint
This module still returns the message used by the service plus its test.
Compare your message.test.mjs with this unchanged reference.
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildMessage } from './message.mjs';
test('buildMessage returns the pipeline message', () => {
assert.deepStrictEqual(buildMessage(), {
message: 'Hello from GitHub through Jenkins, Docker, and Kubernetes!',
});
});
Test checkpoint
This test still verifies the exact message packaged inside the local image.
You have made the image acquisition boundary visible through a repeatable pipeline failure. Next, you will change the pull policy so the cluster can use the image Jenkins already built.
Fix the Image Policy and Deploy
Your previous Jenkins build proved the Test stage works. It also proved the Build Image stage works.
Kubernetes blocked the final handoff because it tried to pull a local-only image from a registry. The corrected policy tells the Docker Desktop cluster to use the image Jenkins already built.
In this step, get ready to:
- Correct the image pull policy in the Kubernetes manifest.
- Deploy the corrected commit through Jenkins.
- Verify the Kubernetes Service through port forwarding.
Change the image pull policy
An image pull policy controls how Kubernetes obtains a container image. The value Never keeps this local workflow inside Docker Desktop's shared image store.
- Return to k8s/app.yaml in the code editor you used earlier.
- Find the line imagePullPolicy: Always.
- Replace it with imagePullPolicy: Never.
- Save k8s/app.yaml.
Why Never Works Locally
Never stops Kubernetes from contacting a registry for this container image. Docker Desktop's local cluster can start the image that Jenkins already built in the shared local image store.
✔️ Awesome, I've got everything!
Your k8s/app.yaml file is saved with imagePullPolicy: Never.
ⓧ I'd like to double check the full code
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello-cicd
spec:
replicas: 1
selector:
matchLabels:
app: hello-cicd
template:
metadata:
labels:
app: hello-cicd
spec:
containers:
- name: hello-cicd
image: hello-cicd:local
imagePullPolicy: Never
ports:
- containerPort: 3000
---
apiVersion: v1
kind: Service
metadata:
name: hello-cicd
spec:
selector:
app: hello-cicd
ports:
- port: 3000
targetPort: 3000
What Should This Manifest Contain?
- The Deployment requests one replica of hello-cicd:local.
- The pull policy keeps Kubernetes from requesting the image from a registry.
- The Service sends traffic on port 3000 to the matching Pod.
Publish the fix through Jenkins
The manifest is desired state stored in GitHub. Jenkins needs the corrected commit before it can reconcile the cluster.
- Publish the saved manifest to main by running these commands:
git add .
git commit -m "Fix local image pull policy"
git push origin main
What Do These Commands Do?
- The first command stages the saved policy change.
- The second command records the change in the repository history.
- The third command publishes the commit to the remote main branch.
You should see a new commit created locally. The push output should show main updating on origin.
Push Rejected?
Check that your Terminal tab is still inside the hello-cicd directory. Complete the sign-in flow if Git asks you to authenticate with GitHub.
Help me troubleshoot this Git push.
Jenkins checks out the latest commit from main. This run applies the corrected manifest before restarting the Deployment.
Before you start the build, do you expect the Deploy stage to keep searching a registry or use the local image?
- Return to the Jenkins Dashboard from earlier.
- Select the hello-cicd item.
- Click Build Now.
- Wait for the current pipeline run to finish.
You'll see green results for Test, Build Image, plus Deploy. The Deploy log reports deployment "hello-cicd" successfully rolled out.
You turned a failed rollout into a repeatable deployment. The pipeline now reaches the local cluster end to end.
Still Seeing ImagePullBackOff?
Confirm the committed line says imagePullPolicy: Never. Check that the Jenkins run used the commit you just pushed.
Help me debug the failed rollout.
Forward the Service and verify the response
Jenkins has deployed the Pod. A ClusterIP Service stays inside the cluster.
Port forwarding creates a temporary route from your Mac to that Service. The command keeps the Terminal tab busy until you stop it with Control-C.
Before you start the route, what response do you expect the deployed service to return?
- Start the temporary route to the Service by running this command in the Terminal tab you used for Kubernetes:
kubectl port-forward service/hello-cicd 3000:3000
What Does This Command Do?
Port forwarding maps port 3000 on your Mac to port 3000 on the Kubernetes Service. The request reaches the ready Pod selected by app: hello-cicd.
- Return to your browser from earlier.
- Open a new browser tab.
- Enter http://localhost:3000 in the address bar.
You'll see {"message":"Hello from GitHub through Jenkins, Docker, and Kubernetes!"} in the browser.
You now have a working delivery path from GitHub through Jenkins into Docker Desktop Kubernetes.
Browser Response Missing?
Check that the port-forward command is still running in its Terminal tab. Confirm the latest Jenkins build completed successfully.
Help me debug the missing service response.
- Return to the Terminal tab running the port forward.
- Stop the temporary route by pressing Control-C.
You'll see your shell prompt return. The deployed Service remains available inside Kubernetes.
Secret mission
Scale the Deployment to Three Replicas
Turn one healthy Pod into three by changing the desired state in k8s/app.yaml. Let Jenkins reconcile Kubernetes from the committed manifest. Prove all three replicas are running.
Clean Up Your Resources
Clean Up Your Resources
Everything in this project runs on your Mac or in a free GitHub repository. There are no paid cloud resources or ongoing project charges.
Decide whether to keep your resources running, pause them for later, or clean up the local runtime resources.
Resources you used:
- The Jenkins process running at http://localhost:8080.
- The Kubernetes Deployment and Service named hello-cicd.
- The Docker image hello-cicd:local.
Keep everything running
No action is needed. Choose this option if you plan to continue with the SonarQube, Trivy, or OWASP Dependency-Check follow-on projects.
- Keep the Jenkins Terminal tab running.
- Keep the Docker Desktop Kubernetes cluster running.
- Keep the public hello-cicd repository available as the source for future pipeline stages.
- Keep the local hello-cicd:local image available to the three running Pods.
Pause - I'll come back to this later
Stop the running processes to free local memory. Your repository, Jenkins data, Docker image, manifests, and pipeline history remain available.
- Stop any active port-forward by pressing Control-C in its Terminal tab.
- Stop Jenkins by pressing Control-C in its Terminal tab.
- Return to the Kubernetes view in Docker Desktop.
- Select Stop to pause the local cluster.
Your project stays intact. You can restart these local processes when you return.
Delete - I don't want to use this again
Remove the Kubernetes workloads and the local Docker image. This cleanup preserves the public repository and Jenkins home data for the follow-on learning path.
- Stop any active port-forward by pressing Control-C in its Terminal tab.
- Stop Jenkins by pressing Control-C in its Terminal tab.
- Switch to the Terminal tab that is inside the hello-cicd directory.
- Remove the local runtime resources by running these commands:
kubectl delete -f k8s/
docker image rm hello-cicd:local
What do these commands remove?
- The first command reads the manifests in k8s/. It removes the hello-cicd Deployment and Service from the local cluster.
- The second command removes the local hello-cicd:local image after the Pods have stopped using it.
- Confirm Terminal reports that the Deployment and Service were deleted.
- Confirm Terminal reports that the local image was removed.
- Return to the Kubernetes view in Docker Desktop.
- Select Stop to pause the local cluster.
Cleanup command failed?
- Confirm the Docker Desktop Kubernetes cluster is running before retrying the first command.
- Stop any container still using hello-cicd:local before retrying the second command.
- Help me clean up my local CI/CD resources.
Nice Work!
Nice Work!
You did it! You built a local CI/CD pipeline that carries committed code from GitHub through Jenkins into Docker and Kubernetes.
You've learned how to:
- Create a version-controlled Node.js JSON service with automated tests. Keep its source files in one repository. Store its tests there too. Include its Dockerfile, Jenkinsfile, and Kubernetes manifests.
- Define a Jenkins Declarative Pipeline as Pipeline as Code. Its stages test the service. They build hello-cicd:local. They deploy the image to the local cluster.
- Diagnose an ImagePullBackOff failure caused by imagePullPolicy: Always. Repair the Deployment with imagePullPolicy: Never. Verify the rollout. Serve the JSON response through a Kubernetes Service.
- Complete the optional Secret Mission by storing a desired state of three replicas in k8s/app.yaml. Verify that three Pods reach Running after Jenkins reconciles the cluster.
Ready to quiz yourself?