Futuristic Tech Nodes
NOTES FROM THE CLOUD TRENCHES
CI/CDGitHub ActionsDevOpsAzure

CI/CD with GitHub Actions: Build, Test and Deploy a Small App Step by Step

Raphael Gab-Momoh

Raphael Gab-Momoh

Senior DevOps Instructor, SKILL.SCH

10/13/2026 16 min read
Share LinkedIn WhatsApp
CI/CD with GitHub Actions: Build, Test and Deploy a Small App Step by Step

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:

  1. Check out the code.
  2. Install dependencies.
  3. Run linting and tests.
  4. Build an artefact (a zip file, a container image, a compiled binary).
  5. Deploy the artefact to an environment.
  6. 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:

ToolWhere it runsConfig fileGood to know
GitHub ActionsGitHub-hosted or self-hosted runners.github/workflows/*.ymlHuge marketplace of reusable actions; tight GitHub integration
GitLab CI/CDGitLab runners (shared or your own).gitlab-ci.ymlPopular in companies that self-host GitLab
Azure PipelinesMicrosoft-hosted or self-hosted agentsazure-pipelines.ymlCommon in Microsoft-heavy enterprises and banks
JenkinsYour own serversJenkinsfileVery flexible, older, needs maintenance
CircleCIHosted or self-hosted.circleci/config.ymlFast, 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_request or workflow_dispatch (a manual button).
  • Job: a group of steps that runs on one runner. Jobs run in parallel unless you say one needs another.
  • Step: a single command (run:) or a reusable action (uses:).
  • Runner: the machine that runs the job. ubuntu-latest is 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 express

Create 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/health

This 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/" > .gitignore

Step 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 main

Replace 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 test

A few things worth understanding rather than copying:

  • on: runs the workflow on pushes to main and on pull requests targeting main. Pull request runs are where CI earns its keep: you see a red cross before the code is merged.
  • npm ci installs exactly what is in package-lock.json. It is faster and more predictable than npm install in automation. This is why you must commit package-lock.json.
  • cache: npm reuses downloaded packages between runs, which saves minutes.
  • The @v4 after 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 push

Open 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 test status 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-endpoint

Open 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/$RG

Notice 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/health

Walk through what changed:

  • Three jobs in a chain. build needs test, and deploy needs build. If tests fail, nothing is built or deployed.
  • The `if:` condition on deploy means pull requests run tests and build, but only a push to main (or a manual run from main) deploys.
  • `permissions: id-token: write` is what allows the job to request an OIDC token. Without it, azure/login fails. The top-level permissions: contents: read keeps every other job minimal.
  • Build once, deploy that artefact. The zip produced in build is 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 /health after deployment. --fail makes curl return 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:

  1. Linting with ESLint as another step in the test job.
  2. A staging environment: deploy to a staging slot or second app first, test it, then promote to production.
  3. 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.
  4. Infrastructure as code: create the resource group, plan and web app with Terraform in the pipeline instead of typing az commands. See Terraform for beginners.
  5. 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 ci fails 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 main branch 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 runs npm ci --omit=dev before packaging.
  • Federated credential mismatch. The subject must 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_ID

A 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 main and 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: matrix in the GitHub Actions documentation).
  • Add a workflow_dispatch trigger 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.