CI/CD Pipelines in Practice: A Hands-On GitHub Actions Setup for Web Apps
CI/CD (Continuous Integration / Continuous Delivery) is the difference between “it works on my machine” and “it works every time.” For junior/mid web developers, the fastest win is a pipeline that automatically:
- installs dependencies
- runs linting + tests
- builds the app
- optionally ships an artifact or deploys
This article walks you through a practical GitHub Actions pipeline you can copy into a real project. We’ll cover caching, pull request checks, branch protection-friendly status checks, and a simple “release” workflow.
What We’re Building
We’ll create two workflows:
ci.yml: runs on every pull request + push, performs lint/test/buildrelease.yml: runs when you tag a release, produces build artifacts
Examples use a typical web stack: Node.js (frontend or fullstack), but the same structure works for Python/Go/etc. You can adapt commands like npm test to your project.
Baseline Project Scripts
Your package.json should have clear scripts so CI can run the same commands developers run locally:
{ "scripts": { "lint": "eslint .", "test": "vitest run", "build": "vite build" } }
If you’re using Jest, replace vitest run with jest --ci. If your build tool is Next.js, your build script might be next build.
Workflow 1: CI for Pull Requests and Main Branch
Create a file at .github/workflows/ci.yml:
name: CI on: pull_request: branches: [ "main" ] push: branches: [ "main" ] concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: test: name: Lint, Test, Build (Node ${{ matrix.node }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: node: [18, 20] steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} cache: "npm" - name: Install deps run: npm ci - name: Lint run: npm run lint - name: Unit tests run: npm test -- --ci env: CI: true - name: Build run: npm run build - name: Upload build artifact (main only) if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/upload-artifact@v4 with: name: web-build-node-${{ matrix.node }} path: | dist if-no-files-found: error
Why This CI Setup Works Well
- Runs on PRs and main: PR checks prevent broken code from merging. Main branch builds keep your baseline green.
- Matrix testing: running Node 18 and 20 catches version-specific issues early.
- Caching:
actions/setup-nodewithcache: "npm"speeds up installs across runs. - Concurrency: if you push multiple commits quickly, GitHub cancels older runs, saving minutes and money.
- Artifacts: on main pushes, it uploads a build output so you can inspect or reuse it later.
Tip: If your build output folder isn’t dist, adjust the path (e.g., .next, build).
Making CI “Branch Protection Ready”
Once this workflow runs, go to GitHub:
Settings→Branches→Branch protection rules- Require status checks to pass before merging
- Select the checks produced by the workflow (e.g.,
Lint, Test, Build (Node 20))
Now nobody can merge a PR that fails lint/tests/build.
Common CI Failure: Tests Need a Database
If your tests depend on Postgres/MySQL/Redis, use “service containers” in GitHub Actions. Here’s a Postgres example:
jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_USER: app POSTGRES_PASSWORD: app POSTGRES_DB: app_test ports: - 5432:5432 options: >- --health-cmd="pg_isready -U app" --health-interval=10s --health-timeout=5s --health-retries=5 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: "npm" - run: npm ci - run: npm test env: DATABASE_URL: postgresql://app:app@localhost:5432/app_test
This spins up Postgres for the job, waits for it to become healthy, then runs tests pointing at localhost.
Workflow 2: Release Builds on Git Tags
A clean pattern is: CI runs on PRs/main, and releases run only when you create a tag like v1.2.0. Create .github/workflows/release.yml:
name: Release on: push: tags: - "v*" jobs: build: runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: 20 cache: "npm" - name: Install deps run: npm ci - name: Build run: npm run build - name: Package build run: | tar -czf web-dist.tar.gz dist - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: files: web-dist.tar.gz
Now creating a tag triggers a release with an attached build artifact. To create a tag:
git tag v1.0.0 git push origin v1.0.0
Adding a Simple Deploy Step (Optional)
Deployment depends on your platform, but the pattern is always the same: store secrets in GitHub, use them in Actions, and run a CLI deploy. As an example, here’s a generic “run a deploy script” approach:
- name: Deploy if: startsWith(github.ref, 'refs/tags/v') run: ./scripts/deploy.sh env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Put your platform-specific logic in scripts/deploy.sh so your workflow stays readable.
CI/CD Best Practices You Can Apply Immediately
- Keep commands identical locally and in CI: always run
npm run lint,npm test,npm run buildboth places. - Fail fast on lint: run lint before tests/build to save time.
- Use
npm ciin CI: it installs from the lockfile deterministically. - Upload artifacts for debugging: build output, coverage reports, screenshots from E2E tests.
- Pin action major versions:
@v4,@v2 - Use
concurrency: avoid duplicate runs for the same branch.
Troubleshooting Checklist
- Build output folder wrong: verify
dist/build/.nextand updateupload-artifactpath. - Tests flaky in CI only: set
CI=true, reduce parallelism, and make tests deterministic (avoid real timeouts). - Node version mismatch: align local Node with the workflow matrix (consider adding
.nvmrc). - Missing environment variables: define them in workflow
envor as GitHub Secrets, never hardcode.
Wrap-Up
With two small YAML files, you’ve built a practical CI/CD system: PRs get automatic validation, main stays healthy, and releases are repeatable. The key is consistency—make CI run the exact same scripts developers run locally, and keep the workflow readable so the team can maintain it.
If you want, tell me your stack (React/Next/Vue, Node/Python backend, DB, hosting provider) and I’ll adapt these workflows to include caching for your build tool, database services, and a concrete deploy step.
Leave a Reply