DEV Community

CI/CD Pipelines Explained: A Hands-On Guide for Developers

CI/CD Pipelines Explained: A Hands-On Guide for Developers

If you've ever pushed code, waited for a teammate to manually deploy it, and then found out three days later that it broke staging — this post is for you. We're going to build a real CI/CD pipeline from scratch, understand why each piece exists, and leave you with something you can actually use on your next project.

No fluff, no "CI/CD will transform your business" energy. Just the mechanics.

Table of Contents

  1. What CI/CD Actually Means (Without the Buzzwords)
  2. Setting Up Your First Pipeline
  3. A Real Example: Node.js App with GitHub Actions
  4. Adding Tests, Linting, and Build Steps
  5. Deploying Automatically
  6. Practical Exercises
  7. Troubleshooting Common Failures
  8. Best Practices That Actually Matter
  9. Performance Tips
  10. Learning Resources

1. What CI/CD Actually Means

Continuous Integration (CI) is the practice of merging code changes frequently — multiple times a day, ideally — and automatically verifying each change with builds and tests. The point isn't "integration" as a buzzword; it's catching the moment code breaks, not three weeks later when nobody remembers what changed.

Continuous Delivery (CD) means your code is always in a deployable state. Every merge to your main branch could go to production without extra manual work.

Continuous Deployment (the more aggressive cousin of Delivery) means it actually does go to production automatically, no human clicking "approve."

Here's the mental model that helped me most: think of CI/CD as a series of gates. Code has to pass through each gate (lint → test → build → deploy) and if it fails any gate, it stops there. No gate, no main.

Push Code → Lint → Test → Build → Deploy to Staging → (Manual Approval) → Deploy to Prod
Enter fullscreen mode Exit fullscreen mode

2. Setting Up Your First Pipeline

We'll use GitHub Actions because it's free for public repos, built into GitHub, and you don't need to provision any separate infrastructure to start.

Every GitHub Actions pipeline lives in .github/workflows/ as a YAML file. Let's create the skeleton:

mkdir -p .github/workflows
touch .github/workflows/ci.yml
Enter fullscreen mode Exit fullscreen mode

Here's the minimal pipeline that does nothing useful yet, but proves the plumbing works:

name: CI

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

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      - name: Say hello
        run: echo "Pipeline is alive"
Enter fullscreen mode Exit fullscreen mode

Push this, open the "Actions" tab on your GitHub repo, and you should see a green checkmark. That's your pipeline running on GitHub's servers, not yours.

3. A Real Example: Node.js App with GitHub Actions

Let's make this concrete with an actual Express app.

mkdir cicd-demo && cd cicd-demo
npm init -y
npm install express
npm install --save-dev jest supertest
Enter fullscreen mode Exit fullscreen mode

index.js:

const express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.status(200).json({ message: 'Hello CI/CD' });
});

app.get('/health', (req, res) => {
  res.status(200).json({ status: 'ok' });
});

module.exports = app;

if (require.main === module) {
  const PORT = process.env.PORT || 3000;
  app.listen(PORT, () => console.log(`Running on port ${PORT}`));
}
Enter fullscreen mode Exit fullscreen mode

index.test.js:

const request = require('supertest');
const app = require('./index');

describe('GET /', () => {
  it('returns hello message', async () => {
    const res = await request(app).get('/');
    expect(res.statusCode).toBe(200);
    expect(res.body.message).toBe('Hello CI/CD');
  });
});

describe('GET /health', () => {
  it('returns ok status', async () => {
    const res = await request(app).get('/health');
    expect(res.statusCode).toBe(200);
  });
});
Enter fullscreen mode Exit fullscreen mode

Add a test script to package.json:

"scripts": {
  "start": "node index.js",
  "test": "jest"
}
Enter fullscreen mode Exit fullscreen mode

Run it locally first:

npm test
Enter fullscreen mode Exit fullscreen mode

You should see both tests pass. Now let's wire this into the pipeline.

4. Adding Tests, Linting, and Build Steps

Update .github/workflows/ci.yml:

name: CI

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

jobs:
  build-and-test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [18.x, 20.x]

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npx eslint . --max-warnings=0
        continue-on-error: true

      - name: Run tests
        run: npm test

      - name: Build
        run: echo "No build step for this simple app, but this is where you'd run npm run build"
Enter fullscreen mode Exit fullscreen mode

A few things worth calling out here:

  • npm ci not npm installci does a clean install from package-lock.json and is faster and more deterministic in pipelines. npm install can silently update your lockfile, which you don't want in CI.
  • The matrix strategy runs your tests against multiple Node versions in parallel. This catches "works on my machine" bugs caused by version drift.
  • cache: 'npm' speeds up subsequent runs by caching node_modules based on your lockfile hash.

Commit and push. Watch it run across two Node versions in the Actions tab.

5. Deploying Automatically

Now the part everyone actually wants: shipping code without SSH-ing into a server.

We'll deploy to Render here because it has a generous free tier and a simple API, but the same pattern applies to Vercel, Railway, AWS, or anywhere else. This example uses a deploy hook, which is the simplest possible integration.

Add a deploy job that only runs after tests pass, and only on main:

  deploy:
    needs: build-and-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - name: Trigger deploy
        run: |
          curl -X POST "${{ secrets.DEPLOY_HOOK_URL }}"
Enter fullscreen mode Exit fullscreen mode

Set DEPLOY_HOOK_URL in your repo under Settings → Secrets and variables → Actions → New repository secret. Never hardcode secrets in your YAML — that's an instant security incident waiting to happen.

If you're deploying to AWS instead, a more realistic snippet using the AWS CLI looks like this:

  deploy-aws:
    needs: build-and-test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-south-1

      - name: Deploy to S3
        run: aws s3 sync ./build s3://your-bucket-name --delete

      - name: Invalidate CloudFront cache
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
            --paths "/*"
Enter fullscreen mode Exit fullscreen mode

This pattern — sync to S3, invalidate CloudFront — is one of the most common static-site/SPA deployment flows on AWS. If you're newer to this side of things, this is roughly the kind of workflow covered in a hands-on AWS course in Bangalore, where deploy pipelines like this get built end-to-end rather than just read about.

6. Practical Exercises

Don't just read this — do these:

Exercise 1: Break it on purpose
Change expect(res.body.message).toBe('Hello CI/CD') to expect the wrong string. Push it. Watch the pipeline fail. This is the most underrated learning exercise — see what a real failure looks like in the Actions logs before you're debugging one under pressure.

Exercise 2: Add a status badge
Add this to your README.md, replacing the placeholders:

![CI](https://github.com/USERNAME/REPO/actions/workflows/ci.yml/badge.svg)
Enter fullscreen mode Exit fullscreen mode

Exercise 3: Branch protection
Go to Settings → Branches → Add rule for main. Require the CI check to pass before merging. Try opening a PR with a failing test and see GitHub block the merge button.

Exercise 4: Add a manual approval gate
Use environment: production with required reviewers in GitHub's environment settings, so deploys pause for a human click before going live. This is genuinely how a lot of teams do Continuous Delivery rather than full Continuous Deployment.

7. Troubleshooting Common Failures

"Process completed with exit code 1" and nothing else useful
This usually means a step failed silently. Add --verbose flags to your test runner or check "Re-run jobs with debug logging" (available from the failed run's page).

Tests pass locally but fail in CI
Almost always one of: timezone differences, environment variables not set in CI, or a node_modules mismatch. Run npm ci locally (not npm install) to reproduce the exact CI environment.

"npm ci" fails with lockfile errors
Your package.json and package-lock.json are out of sync. Run npm install locally, commit the updated lockfile, and push again.

Secrets not being read (undefined in logs)
Secrets aren't available on workflows triggered by PRs from forks, by design, for security reasons. If you need this, look into pull_request_target — but be very careful, since it runs with access to your repo's secrets against untrusted code.

Cache not speeding anything up
Check that your cache key actually depends on package-lock.json. A stale or overly broad cache key means you're caching nothing useful.

Deploy step succeeds but the site didn't change
Check your CDN/cache invalidation step. This is the single most common "why didn't my deploy work" issue — the deploy succeeded, but a cached version is still being served.

8. Best Practices That Actually Matter

  • Fail fast. Put your cheapest, fastest checks (lint, type-check) before expensive ones (full test suite, build). No point waiting 10 minutes for tests if a lint error would've told you in 10 seconds.
  • Never deploy from your laptop once CI/CD exists. If deploys can happen outside the pipeline, your pipeline isn't the source of truth anymore, and you'll eventually deploy something untested.
  • Pin your action versions. Use actions/checkout@v4, not actions/checkout@main. Untagged/floating versions can change behavior under you without warning.
  • Keep secrets out of logs. GitHub masks secret values automatically, but be careful not to echo them directly or pass them through commands that might print them on error.
  • One pipeline, multiple environments. Don't maintain separate YAML files for staging and production if you can parameterize one with environment variables/conditionals instead — it's less to keep in sync.
  • Make failures loud, not silent. Hook up Slack or email notifications for failed deploys. A red X nobody sees is as good as no pipeline at all.

9. Performance Tips

  • Cache dependencies. We already saw cache: 'npm' — this alone can cut build time by 30-60% on larger projects.
  • Run jobs in parallel where they don't depend on each other. Lint and unit tests usually don't need to run sequentially — split them into separate jobs.
  • Use needs: carefully. Only add dependencies between jobs when one genuinely requires the other's output. Unnecessary needs: chains serialize work that could run concurrently.
  • Shallow clone when you don't need history. actions/checkout@v4 does this by default (fetch-depth: 1), but if you've customized it, don't fetch full git history unless something actually needs it (like changelog generation).
  • Self-hosted runners for heavy workloads. If you're regularly waiting on GitHub-hosted runners' limited resources (2 CPU cores, 7GB RAM on the free tier), a self-hosted runner on your own VM removes that ceiling — at the cost of managing the machine yourself.

10. Learning Resources

  • GitHub Actions official docs — genuinely well-written, start here
  • GitHub Actions Marketplace — reusable actions instead of writing everything from scratch
  • "Continuous Delivery" by Jez Humble and David Farley — the book that defined a lot of this practice; dense but foundational
  • 12factor.net — not CI/CD specific, but shapes how you should structure apps to be deployable this way
  • The awesome-actions GitHub repo — a curated list of community actions worth knowing about
  • If you want this taught hands-on rather than self-paced, devops training in BTM Layout, Bangalore covers pipeline tooling like this as part of a broader DevOps curriculum
  • For the AWS deploy side specifically (S3, CloudFront, IAM roles for CI), an AWS course in Bangalore is a reasonable next step once you're comfortable with the basics above

Wrapping Up

The core idea of CI/CD isn't complicated: automate the boring, error-prone parts of shipping code so humans only step in for decisions that actually need judgment. Everything above — the matrix builds, the deploy gates, the caching — is just refinement on that one idea.

Start with the skeleton pipeline from Section 2. Get it green. Then add one gate at a time. Don't try to build the "perfect" pipeline on day one; you'll learn more from watching a real deploy fail and fixing it than from any tutorial, including this one.

If you build something off this, I'd genuinely like to hear what broke first — that's usually the most interesting part.

Top comments (0)