# Your First CI/CD Pipeline with GitHub Actions

URL: https://www.devopsness.com/blog/first-cicd-pipeline-github-actions-tutorial
Author: Kiril Urbonas
Published: 2026-04-29
Updated: 2026-10-12
Categories: DevOps
Tags: GitHub Actions, CI/CD, DevOps, Tutorial, Beginner

Summary: Walk through a working GitHub Actions workflow — install, test, build, deploy — for a tiny Node app. Every line explained.

---
# Your First CI/CD Pipeline with GitHub Actions

By the end of this post you'll have a working CI/CD pipeline that runs tests on every push, builds a Docker image, and deploys it — all triggered by a `git push`. We'll use a tiny Node.js app and GitHub Actions. About 30 minutes.

You'll need a GitHub account, Node.js 20+ installed locally, and Docker installed.

## What CI/CD actually means

**CI (Continuous Integration)** is the practice of running checks (tests, linting, builds) automatically every time you push code. The point: you find out something broke within minutes, not days.

**CD (Continuous Deployment / Delivery)** is the practice of automatically pushing code that passes CI to a real environment. Continuous *Deployment* goes all the way to production; Continuous *Delivery* stops at staging and waits for a human to click deploy.

GitHub Actions is one tool for running CI/CD. The same patterns apply to GitLab CI, CircleCI, Jenkins, and the rest. We'll focus on Actions because it's free for public repos and built into GitHub.

## Step 1: Create a tiny app

```bash
mkdir hello-ci && cd hello-ci
npm init -y
npm install express
npm install --save-dev jest supertest
```

Edit `package.json` and add scripts:

```json
{
  "scripts": {
    "start": "node index.js",
    "test": "jest"
  }
}
```

Create `index.js`:

```javascript
const express = require("express");
const app = express();

app.get("/", (req, res) => {
  res.json({ message: "Hello from CI/CD!", time: new Date().toISOString() });
});

if (require.main === module) {
  app.listen(3000, () => console.log("Listening on 3000"));
}

module.exports = app;
```

Create `index.test.js`:

```javascript
const request = require("supertest");
const app = require("./index");

test("root returns greeting", async () => {
  const res = await request(app).get("/");
  expect(res.status).toBe(200);
  expect(res.body.message).toContain("Hello");
});
```

Run it locally to confirm it works:

```bash
npm test
```

You should see one test pass.

## Step 2: Add a Dockerfile

We'll build the app into a container as part of the pipeline. Save this as `Dockerfile`:

```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY index.js .
EXPOSE 3000
CMD ["node", "index.js"]
```

Test the build locally:

```bash
docker build -t hello-ci:local .
docker run --rm -p 3000:3000 hello-ci:local
# In another terminal: curl http://localhost:3000
```

## Step 3: Push to GitHub

Create a new repo on github.com (no README, no gitignore — keep it empty). Then locally:

```bash
git init
git add .
git commit -m "initial"
git branch -M main
git remote add origin https://github.com/<your-username>/hello-ci.git
git push -u origin main
```

GitHub now has your code. Time to add the pipeline.

## Step 4: Write the workflow

GitHub Actions workflows live in `.github/workflows/<name>.yml`. Create `.github/workflows/ci.yml`:

```yaml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

  build:
    runs-on: ubuntu-latest
    needs: test
    steps:
      - uses: actions/checkout@v4

      - name: Build Docker image
        run: docker build -t hello-ci:${{ github.sha }} .
```

What each part does:

- **`on:`** — triggers. Run on every push to main and on every PR opened against main.
- **`jobs:`** — independent units of work. Each gets its own VM.
- **`runs-on: ubuntu-latest`** — GitHub provides a fresh Ubuntu VM for each run.
- **`uses: actions/checkout@v4`** — clones your repo into the VM.
- **`uses: actions/setup-node@v4`** — installs Node.js. The `cache: "npm"` line caches `node_modules` between runs to make the next build faster.
- **`needs: test`** — the build job waits for the test job to pass before running.
- **`${{ github.sha }}`** — built-in variable for the current commit SHA, used as the image tag.

Commit and push:

```bash
git add .github/workflows/ci.yml
git commit -m "add CI workflow"
git push
```

Within seconds, go to your repo's "Actions" tab on GitHub. You should see a run starting, with two jobs: `test` and `build`. After about 30 seconds both should turn green.

## Step 5: Make it fail and watch the feedback

To verify the pipeline actually catches problems, break the test:

```javascript
// in index.test.js, change "Hello" to "Goodbye"
expect(res.body.message).toContain("Goodbye");
```

```bash
git commit -am "intentional break"
git push
```

The `test` job will fail. The `build` job (which depended on `test`) will be skipped. GitHub will email you about the failure. You'll also see a red ✗ next to the commit on the repo's main page.

Revert the test:

```bash
# change "Goodbye" back to "Hello"
git commit -am "fix"
git push
```

Pipeline goes green again.

## Step 6: Add a deploy step (optional)

Real deploy steps depend on where you're deploying. A minimal pattern that works for Vercel, Fly.io, AWS, etc.:

```yaml
  deploy:
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: ./deploy.sh
```

The `if: github.ref == 'refs/heads/main'` ensures deploys only happen for the main branch, not PRs. The `secrets.DEPLOY_TOKEN` is a GitHub repo secret you'd configure under Settings → Secrets and variables → Actions.

## Common mistakes

**Storing tokens in the workflow file.** Anything committed to the repo is public for public repos. Always use `${{ secrets.NAME }}` and configure the actual value in the GitHub UI.

**Skipping `npm ci` in favor of `npm install`.** `npm ci` reads the lockfile and refuses to update it — meaning CI gets exactly the dependencies you tested with. `npm install` may resolve fresh versions and break things.

**Forgetting `needs:` between jobs.** Without `needs:`, jobs run in parallel. That sounds fast but means you might deploy code that hasn't been tested yet. Use `needs:` to enforce order.

**Long-running workflows.** A pipeline that takes 20 minutes blocks every PR. Cache aggressively (npm, Docker layers, build outputs) and parallelize. Aim for under 5 min.

## What to read next

You have a working pipeline. The next levels:

- [Scalable CI/CD with GitHub Actions](/blog/scalable-cicd-pipelines-github-actions) — patterns we use across 80 repos
- [CI/CD pipeline optimization: where the time goes](/blog/speed-up-ci-pipeline) — how to cut a 28-minute build to 6 minutes
- [Canary releases: a gradual rollout strategy](/blog/canary-releases-gradual-rollout-strategy) — the next step after "automated deploy"

CI/CD isn't a complex technology. It's a discipline of running the same checks every time. Once you have a basic pipeline running, every improvement is incremental — faster, more checks, smarter deploys.

