CI/CD Pipelines for Web Apps: A Practical GitHub Actions Setup (Test → Build → Deploy)
CI/CD (Continuous Integration / Continuous Delivery) is just a repeatable way to answer: “Did my change break anything?” and “Can I ship it safely?” For junior/mid developers, the fastest win is a pipeline that:
- runs on every pull request
- lints + tests your app
- builds an artifact (or Docker image)
- deploys only from the main branch
This article shows a hands-on GitHub Actions pipeline you can drop into most web repos. I’ll use a Node.js API example, but the pattern applies to Python, PHP, etc.
What we’re building
We’ll create two workflows:
ci.yml: runs on PRs and pushes (lint + test + build)deploy.yml: runs only whenmainchanges, deploys via SSH to a server
If you don’t deploy via SSH (maybe you use Kubernetes, Heroku-like platforms, or a PaaS), you can still keep the same CI workflow and swap the deploy step.
Example project structure
Assume your repo is something like:
my-app/ src/ package.json package-lock.json Dockerfile .github/ workflows/
Your package.json should expose scripts for the pipeline to call:
{ "scripts": { "lint": "eslint .", "test": "vitest run", "build": "tsc -p tsconfig.json" } }
If you’re not using TypeScript, replace build with whatever produces a distributable output (e.g., bundling front-end assets).
Step 1: A solid CI workflow (PR-friendly)
Create .github/workflows/ci.yml:
name: CI on: pull_request: push: branches: [ "main" ] jobs: test: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Install dependencies run: npm ci - name: Lint run: npm run lint - name: Unit tests run: npm test - name: Build run: npm run build
Why this works well:
npm ciis deterministic (it uses your lockfile exactly).actions/setup-nodewithcache: "npm"speeds up runs.- PRs get fast feedback without shipping anything.
Common improvement: run tests on multiple Node versions to catch “works on my machine” surprises.
- name: Setup Node uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} cache: "npm" strategy: matrix: node: ["18", "20"]
Step 2: Add a database service for integration tests
If your API tests need MySQL/Postgres/Redis, run them as “services” inside the workflow job. Here’s Postgres as an example:
jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres POSTGRES_DB: app_test ports: - 5432:5432 options: >- --health-cmd="pg_isready -U postgres" --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 - name: Run DB migrations env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/app_test run: npm run db:migrate - name: Integration tests env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/app_test run: npm test
Key idea: your tests should run from scratch—no relying on a local database you forgot to reset.
Step 3: Build a Docker image (optional but powerful)
If you ship via containers, build the image in CI. Add this job after tests pass:
docker: runs-on: ubuntu-latest needs: test if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build image (no push) run: | docker build -t my-app:${{ github.sha }} .
This verifies your Dockerfile still builds, without deploying anything yet.
Step 4: Deploy workflow (simple SSH + Docker Compose)
For a straightforward “one VPS” setup, you can deploy by SSHing into the server and running docker compose. Create .github/workflows/deploy.yml:
name: Deploy on: push: branches: [ "main" ] concurrency: group: production cancel-in-progress: true jobs: deploy: runs-on: ubuntu-latest timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Deploy over SSH uses: appleboy/[email protected] with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | set -e cd /var/www/my-app git fetch --all git reset --hard origin/main docker compose pull docker compose up -d --remove-orphans docker image prune -f
What you need on the server:
- your repo cloned at
/var/www/my-app dockeranddocker composeinstalled- a
docker-compose.ymlthat defines your app
Example docker-compose.yml (on the server):
services: app: image: my-registry.example.com/my-app:latest env_file: - .env ports: - "3000:3000" restart: unless-stopped
In a “real” setup you’d likely build and push the image to a registry, then docker compose pull grabs the latest. If you don’t have a registry yet, you can deploy by building on the server—but pushing images is usually faster and more repeatable.
Step 5: Secrets and environment variables (don’t leak them)
In GitHub, set repository secrets under Settings → Secrets and variables → Actions:
SSH_HOST(e.g.,203.0.113.10)SSH_USER(e.g.,deploy)SSH_PRIVATE_KEY(the private key that matches a public key in~/.ssh/authorized_keyson the server)
Rule of thumb: Secrets go in GitHub Secrets; runtime config goes in a server-side .env (or a managed secret store). Never commit real tokens into the repo.
Step 6: Add safety rails (so deploys don’t surprise you)
- Require PR checks: in GitHub branch protection, require the
CIworkflow to pass before merging. - Use
concurrency: prevents two deploys from stepping on each other (already in the example). - Fail fast: use
set -ein deploy scripts so errors stop the deploy immediately. - Rollbacks: tag releases (or images) so you can redeploy a previous version quickly.
A simple rollback approach is to deploy immutable image tags (like ${{ github.sha }}) and keep the last few tags available. Then rolling back is just switching the tag in your server config and running docker compose up -d.
Troubleshooting checklist
- CI is slow: ensure dependency caching is enabled; avoid reinstalling tools repeatedly.
- Tests flaky in CI: add timeouts, avoid relying on exact timing, and make test data setup deterministic.
- Deploy works locally but not in Actions: print versions (
node -v,npm -v), verify secrets exist, and check SSH permissions. - Server runs out of disk: prune old images occasionally (like in the example) and monitor logs.
Where to go next
Once this baseline is in place, you can level up in small steps:
- add code coverage reporting
- upload build artifacts (front-end bundles) for debugging
- run end-to-end tests after deploy in a staging environment
- use environments + required approvals for production deploys
The big win is consistency: every change gets the same checks, and deploys become a boring, reliable button press (or merge). That’s what “good CI/CD” feels like.
Leave a Reply