
Most beginners learn to write code, push it to GitHub and then copy it to a server by hand. That works until the day you forget a step, deploy a broken build on a Friday evening, or cannot remember which version is actually running. CI/CD fixes that. This guide explains what CI/CD means in plain terms, then walks you through building a real pipeline with GitHub Actions: a small Node.js app, automated tests, and a deployment to Azure App Service that runs every time you push to main. You will finish with something you can show in an interview and explain line by line.
What CI/CD actually means
Continuous integration (CI) means every change you push is automatically built and tested. If a test fails, you find out within minutes, not when a customer complains.
Continuous delivery means every change that passes CI is packaged and ready to release, usually with a person pressing the final button. Continuous deployment goes one step further and releases automatically. People use "CD" for both, so it is worth saying which one you mean.
A pipeline is just the list of steps that runs on each change. A typical one looks like this:
- Check out the code.
- Install dependencies.
- Run linting and tests.
- Build an artefact (a zip file, a container image, a compiled binary).
- Deploy the artefact to an environment.
- Check that the deployed app is healthy.
None of this is magic. Every step is a command you could run on your own laptop. The pipeline simply runs those commands the same way every time, on a clean machine, and records the result.
Why GitHub Actions is a sensible place to start
There are many CI/CD tools. GitHub Actions is a good first choice because your code is probably already on GitHub, there is nothing to install, and public repositories get free minutes on GitHub-hosted runners. Private repositories get a monthly allowance of free minutes on the Free plan; check the official GitHub billing page for the current limits, because they change.
Here is how it compares with tools you will see in job adverts:
| Tool | Where it runs | Config file | Good to know |
|---|---|---|---|
| GitHub Actions | GitHub-hosted or self-hosted runners | .github/workflows/*.yml | Huge marketplace of reusable actions; tight GitHub integration |
| GitLab CI/CD | GitLab runners (shared or your own) | .gitlab-ci.yml | Popular in companies that self-host GitLab |
| Azure Pipelines | Microsoft-hosted or self-hosted agents | azure-pipelines.yml | Common in Microsoft-heavy enterprises and banks |
| Jenkins | Your own servers | Jenkinsfile | Very flexible, older, needs maintenance |
| CircleCI | Hosted or self-hosted | .circleci/config.yml | Fast, popular with start-ups |
The concepts transfer. Once you understand jobs, steps, triggers, secrets and artefacts in one tool, learning another is mostly a matter of new syntax. If you want to see the GitLab version later, have a look at our GitLab material.
The building blocks of a GitHub Actions workflow
Before writing anything, learn five words:
- Workflow: a YAML file in
.github/workflows/. One repository can have several. - Event (trigger): what starts the workflow, such as
push,pull_requestorworkflow_dispatch(a manual button). - Job: a group of steps that runs on one runner. Jobs run in parallel unless you say one
needsanother. - Step: a single command (
run:) or a reusable action (uses:). - Runner: the machine that runs the job.
ubuntu-latestis a fresh Linux virtual machine that GitHub creates and destroys for each job.
Because the runner is Linux, basic shell skills matter a lot here. If commands such as cd, ls, cat and environment variables still feel shaky, spend a few evenings in the Linux Arena first, and read our guide to Linux skills for cloud jobs.
What you need before you start
- A GitHub account and Git installed locally. If you are new to Git, read Git and GitHub for DevOps first.
- Node.js 22 installed (the current LTS line you are likely to see on cloud platforms; check nodejs.org for the latest).
- An Azure account with the Azure CLI (
az) installed. You can do the CI part without any cloud account at all. - A text editor such as VS Code.
A word on cost. Cloud providers bill in dollars and usually need a card. Before you create any resources, set a budget alert in the Azure portal (Cost Management, then Budgets) for a small amount such as 5 or 10 dollars, and confirm your bank allows international card payments. Many Nigerian cards have monthly limits on foreign spending, so check yours rather than finding out mid-project. We cover this habit in more depth in our FinOps guide. At the end of this guide we delete everything, so the cost should stay very low.
Step 1: Build a tiny app with tests
Create a new folder and initialise the project:
mkdir cicd-demo && cd cicd-demo
git init
npm init -y
npm install expressCreate app.js. Keeping the app separate from the code that starts the server makes it easy to test:
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.json({ message: 'Hello from SKILL.SCH CI/CD demo' });
});
app.get('/health', (req, res) => {
res.status(200).send('ok');
});
function add(a, b) {
return a + b;
}
module.exports = { app, add };Create server.js. App Service tells your app which port to use through the PORT environment variable, so always read it:
const { app } = require('./app');
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Listening on port ${port}`);
});Now a test. Node.js has a built-in test runner, so you do not need any extra library. Create test/app.test.js:
const test = require('node:test');
const assert = require('node:assert');
const { app, add } = require('../app');
test('add() sums two numbers', () => {
assert.strictEqual(add(2, 3), 5);
});
test('GET /health returns ok', async () => {
const server = app.listen(0);
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/health`);
const body = await res.text();
server.close();
assert.strictEqual(res.status, 200);
assert.strictEqual(body, 'ok');
});Edit the scripts section of package.json:
"scripts": {
"start": "node server.js",
"test": "node --test"
}Run it locally before you go anywhere near a pipeline:
npm test
npm start
# in another terminal
curl http://localhost:3000/healthThis is the first rule of CI/CD: if it does not work on your machine, it will not work in the pipeline. The pipeline only automates what you can already do by hand.
Add a .gitignore so you never commit dependencies:
echo "node_modules/" > .gitignoreStep 2: Push the project to GitHub
Create an empty repository on GitHub called cicd-demo (no README, so there is nothing to merge), then:
git add .
git commit -m "Add demo app and tests"
git branch -M main
git remote add origin https://github.com/YOUR-USERNAME/cicd-demo.git
git push -u origin mainReplace YOUR-USERNAME with your GitHub username.
Step 3: Your first CI workflow
Create the folder and file .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm testA few things worth understanding rather than copying:
on:runs the workflow on pushes tomainand on pull requests targetingmain. Pull request runs are where CI earns its keep: you see a red cross before the code is merged.npm ciinstalls exactly what is inpackage-lock.json. It is faster and more predictable thannpm installin automation. This is why you must commitpackage-lock.json.cache: npmreuses downloaded packages between runs, which saves minutes.- The
@v4after each action is a version. Never use an action without a version.
Commit and push:
git add .github
git commit -m "Add CI workflow"
git pushOpen the Actions tab in your repository. You should see the workflow running, then a green tick. Click into it and read the logs for each step. Getting comfortable reading these logs is a core DevOps skill.
Now break it on purpose. Change assert.strictEqual(add(2, 3), 5) to expect 6, push, and watch the run fail. Read the error, fix it, push again. You have just experienced the feedback loop that CI exists to provide.
Step 4: Protect the main branch
CI is most useful when failing code cannot be merged. In your repository go to Settings, then Branches (or Rules, then Rulesets, depending on how GitHub presents it to you), and add a rule for main that:
- requires a pull request before merging, and
- requires the
teststatus check to pass.
From now on, work on a branch:
git checkout -b feature/new-endpoint
# make a change, commit it
git push -u origin feature/new-endpointOpen a pull request on GitHub and watch the check run. This branch-and-pull-request flow is what you will do every day in a real team. You can practise the branching side in the Git Arena.
Step 5: Prepare Azure for deployment
Now the CD part. We will deploy to Azure App Service, which runs web apps without you managing a server. Log in and create the resources. Pick a globally unique app name, for example cicd-demo-yourname:
az login
RG=rg-cicd-demo
LOCATION=westeurope
PLAN=plan-cicd-demo
APP=cicd-demo-yourname
az group create --name $RG --location $LOCATION
az appservice plan create \
--name $PLAN \
--resource-group $RG \
--is-linux \
--sku B1
az webapp create \
--name $APP \
--resource-group $RG \
--plan $PLAN \
--runtime "NODE:22-lts"If NODE:22-lts is rejected, run az webapp list-runtimes --os linux to see the runtimes currently offered and pick the closest Node LTS. The B1 plan is a paid tier billed per hour; Azure also offers a Free (F1) tier with limits, so check the official pricing page and choose what suits your budget. South Africa North is the closest Azure region to West Africa, but not every service or SKU is offered in every region, so West Europe is a safe choice for practice.
Let GitHub log in without a stored password
The old way was to create a service principal with a client secret and paste the secret into GitHub. That secret lives forever and can leak. The better way is OpenID Connect (OIDC): GitHub proves to Azure who it is for each run, and Azure hands back a short-lived token. No long-lived password is stored anywhere.
Create an app registration and give it access to just your resource group:
APP_ID=$(az ad app create --display-name gha-cicd-demo --query appId -o tsv)
az ad sp create --id $APP_ID
SUB_ID=$(az account show --query id -o tsv)
TENANT_ID=$(az account show --query tenantId -o tsv)
az role assignment create \
--assignee $APP_ID \
--role Contributor \
--scope /subscriptions/$SUB_ID/resourceGroups/$RGNotice the --scope. The pipeline can only touch this one resource group, not your whole subscription. That is the principle of least privilege, and interviewers notice when you apply it. Our cloud security basics guide explains why this matters.
Now tell Azure to trust tokens from your repository's main branch. Create credential.json (outside your repo folder, or delete it afterwards):
{
"name": "github-main",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:YOUR-USERNAME/cicd-demo:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}az ad app federated-credential create --id $APP_ID --parameters credential.json
echo "AZURE_CLIENT_ID=$APP_ID"
echo "AZURE_TENANT_ID=$TENANT_ID"
echo "AZURE_SUBSCRIPTION_ID=$SUB_ID"In GitHub, go to Settings, Secrets and variables, Actions, and add those three values as repository secrets with exactly those names. They are identifiers rather than passwords, but keeping them as secrets stops them being printed in logs.
Step 6: Add the deploy job
Replace .github/workflows/ci.yml with a full pipeline. Rename it to pipeline.yml if you like:
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
env:
AZURE_WEBAPP_NAME: cicd-demo-yourname
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install production dependencies only
run: npm ci --omit=dev
- name: Package the app
run: zip -r app.zip . -x ".git/*" ".github/*" "test/*"
- uses: actions/upload-artifact@v4
with:
name: app
path: app.zip
deploy:
needs: build
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
environment: production
permissions:
id-token: write
contents: read
steps:
- uses: actions/download-artifact@v4
with:
name: app
- name: Log in to Azure with OIDC
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy to App Service
uses: azure/webapps-deploy@v3
with:
app-name: ${{ env.AZURE_WEBAPP_NAME }}
package: app.zip
- name: Smoke test
run: |
sleep 30
curl --fail --retry 5 --retry-delay 10 \
https://${{ env.AZURE_WEBAPP_NAME }}.azurewebsites.net/healthWalk through what changed:
- Three jobs in a chain.
buildneedstest, anddeployneedsbuild. If tests fail, nothing is built or deployed. - The `if:` condition on
deploymeans pull requests run tests and build, but only a push tomain(or a manual run frommain) deploys. - `permissions: id-token: write` is what allows the job to request an OIDC token. Without it,
azure/loginfails. The top-levelpermissions: contents: readkeeps every other job minimal. - Build once, deploy that artefact. The zip produced in
buildis the exact thing deployed. You are not rebuilding on the server and hoping it matches. - `environment: production` links the job to a GitHub environment. In Settings, Environments, you can add required reviewers so a person must approve each production deployment. That turns continuous deployment into continuous delivery with one checkbox.
- The smoke test calls
/healthafter deployment.--failmakescurlreturn an error on a 4xx or 5xx response, so a broken deploy shows as a red pipeline instead of silently succeeding.
One catch: the federated credential you created trusts ref:refs/heads/main. If you use environment: production, the token's subject becomes repo:YOUR-USERNAME/cicd-demo:environment:production instead. Add a second federated credential with that subject (same JSON, different name and subject), otherwise the login step will be refused with an error about no matching federated identity.
Commit, push to main (through a pull request if you protected the branch), and watch the three jobs run. When it finishes, open https://cicd-demo-yourname.azurewebsites.net in your browser. Then change the message in app.js, merge it, and watch your change go live without touching the Azure portal.
Where this goes next
Once the basics work, real teams add more. Good next steps, in roughly this order:
- Linting with ESLint as another step in the
testjob. - A staging environment: deploy to a staging slot or second app first, test it, then promote to production.
- Containers: build a Docker image, push it to GitHub Container Registry or Azure Container Registry, and deploy the image. Our Docker for beginners guide and the Docker Arena cover the image side.
- Infrastructure as code: create the resource group, plan and web app with Terraform in the pipeline instead of typing
azcommands. See Terraform for beginners. - Security scanning: enable Dependabot and code scanning in the repository settings.
If you prefer to see these flows drawn out before building them, the CI/CD visualiser shows how stages, artefacts and environments connect.
Common mistakes and how to fix them
- YAML indentation errors. YAML uses spaces, never tabs, and indentation is meaning. If the Actions tab says the workflow file is invalid, check indentation first. A YAML linter extension in your editor saves a lot of time.
- Forgetting `package-lock.json`.
npm cifails without it. Commit it. - Hard-coding secrets. Never put keys, passwords or connection strings in the workflow file or the code. Use repository or environment secrets, and prefer OIDC so there is nothing long-lived to leak.
- Giving the pipeline Owner on the whole subscription. It works, which is why people do it. Scope access to the resource group and the smallest role that does the job.
- Deploying from pull requests. Code from a pull request has not been reviewed yet. Keep deployment behind the
mainbranch condition. - Not reading the logs. Most failures explain themselves in the step output. Expand the failed step and read from the bottom up.
- The app works locally but not on App Service. The usual cause is listening on a fixed port. Read
process.env.PORT. The second most common cause is missing dependencies in the zip, which is why the build job runsnpm ci --omit=devbefore packaging. - Federated credential mismatch. The
subjectmust match the repository owner, repository name and branch or environment exactly, including capital letters. - Leaving resources running. Set a budget alert before you start, and delete practice resources when you finish.
When you are done practising, clean up:
az group delete --name rg-cicd-demo --yes --no-wait
az ad app delete --id $APP_IDA realistic four-week practice plan
This assumes about an hour on weekday evenings and a longer session at the weekend. If power or data is unreliable where you live, do the reading and YAML writing offline, and save the pushing and watching runs for when you have a stable connection. Pipelines run on GitHub's machines, so a dropped connection on your side does not stop a run that has already started.
Week 1: CI only.
- Build the app and tests from Step 1 and run them locally.
- Push to GitHub and add the CI workflow.
- Break a test deliberately and fix it. Repeat until reading the logs feels normal.
- Add ESLint and a lint step.
Week 2: Team workflow.
- Protect
mainand require the status check. - Do every change through a branch and pull request, even working alone.
- Add a matrix to test on two Node versions (look up
strategy: matrixin the GitHub Actions documentation). - Add a
workflow_dispatchtrigger and run it manually.
Week 3: Deployment.
- Set a budget alert, then create the Azure resources.
- Configure OIDC and the federated credentials.
- Add the build and deploy jobs and the smoke test.
- Add a production environment with yourself as required reviewer.
Week 4: Make it portfolio-ready.
- Write a clear README with a diagram of the pipeline and a screenshot of a green run.
- Explain in the README why you chose OIDC and resource-group scope.
- Rebuild the infrastructure with Terraform, or containerise the app.
- Practise explaining the pipeline out loud in five minutes, as you would in an interview.
For more project ideas that build on this one, see cloud portfolio projects, and for the questions you are likely to face, read cloud and DevOps interview questions.
FAQ
Do I need to know a programming language to learn CI/CD?
You need enough to read and run a small app and its tests. You do not need to be a strong developer. Most of the work is YAML, shell commands and understanding how pieces connect. Basic Python or JavaScript helps a lot over time.
Is GitHub Actions free?
Public repositories get free usage on standard GitHub-hosted runners, and private repositories get a monthly allowance on the Free plan. The limits and per-minute prices change, so check the official GitHub billing documentation. The cloud resources you deploy to are billed separately by the cloud provider.
Can I do this on AWS instead of Azure?
Yes. The CI part is identical. For deployment you would use aws-actions/configure-aws-credentials with an IAM role that trusts GitHub's OIDC provider, then deploy to a service such as Elastic Beanstalk, App Runner or ECS. If you are still deciding between providers, read AWS vs Azure: which to learn first.
What is the difference between a GitHub-hosted and a self-hosted runner?
A GitHub-hosted runner is a clean virtual machine GitHub creates for each job and then throws away. A self-hosted runner is a machine you manage and register with GitHub. Companies use self-hosted runners to reach private networks or to control cost and hardware. Start with GitHub-hosted runners.
Will CI/CD skills help me get a job?
Pipelines are a routine part of most DevOps, platform and cloud engineering roles, and being able to show and explain a working one is a strong talking point in interviews. It works best alongside Linux, Git, a cloud platform and containers. Our guide on how to become a DevOps engineer in Nigeria lays out the full path.
Want help building your first pipeline?
Following a guide is a good start, but most people learn fastest with someone reviewing their work and pointing out what interviewers will ask. If you would like a plan matched to your background and schedule, book a free consultation with SKILL.SCH, or browse our courses to see how we teach CI/CD, cloud and DevOps with hands-on projects.

