CI/CD Pipelines with GitHub Actions: Ship on Every Merge (Without Stress)

CI/CD Pipelines with GitHub Actions: Ship on Every Merge (Without Stress)

If you’ve ever merged to main and then manually ran tests, built a Docker image, and SSH’d into a server to deploy… you’ve basically done CI/CD by hand. The goal of a CI/CD pipeline is to automate that workflow so every change is verified and deployable (and ideally deployed) with consistent, repeatable steps.

In this hands-on guide, you’ll build a practical pipeline using GitHub Actions that:

  • Runs lint + tests on every pull request
  • Builds a Docker image and pushes it to a registry
  • Deploys on merges to main (example: SSH to a VM and restart a container)
  • Adds quality-of-life upgrades: caching, concurrency, and environment separation

What We’re Building (Minimal, Realistic Setup)

Assume you have a typical web app repo with:

  • Node.js app (or any app that can run tests) + a Dockerfile
  • Tests runnable via npm test
  • A server (VM) with Docker installed for deployment

Your structure might look like:

. ├─ src/ ├─ package.json ├─ package-lock.json ├─ Dockerfile └─ .github/ └─ workflows/ └─ pipeline.yml

Step 1: CI on Pull Requests (Lint + Tests + Build)

Create .github/workflows/pipeline.yml and start with a CI job that runs on PRs. This gives fast feedback and prevents broken code from merging.

name: CI-CD on: pull_request: branches: [ "main" ] push: branches: [ "main" ] concurrency: group: pipeline-${{ github.ref }} cancel-in-progress: true jobs: ci: name: CI (lint, test, build) runs-on: ubuntu-latest 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 --if-present - name: Test run: npm test - name: Build run: npm run build --if-present

Notes:

  • concurrency cancels older runs when you push new commits to the same branch/PR.
  • npm ci is preferred in CI because it’s reproducible and faster.
  • --if-present lets the workflow work even if your project doesn’t have those scripts yet.

Step 2: Add a Docker Build That Only Runs When CI Passes

Now let’s build a Docker image. The key: only build/push images on push to main (not on PRs), and only if CI succeeds.

We’ll use GitHub Container Registry (GHCR) because it’s convenient for GitHub repos. You can adapt to Docker Hub or ECR later.

 docker: name: Build & Push Docker Image runs-on: ubuntu-latest needs: ci if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: contents: read packages: write steps: - name: Checkout uses: actions/checkout@v4 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }} tags: | type=sha type=raw,value=latest - name: Build and push uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max

This produces two useful tags:

  • latest (easy to deploy)
  • sha-... (traceability: you can deploy an exact commit)

Step 3: CD Deployment via SSH (Simple and Common)

There are many deployment strategies. For junior/mid devs, the most approachable is: SSH into a server, pull the new image, and restart the container.

Server prerequisites:

  • Docker installed
  • A running container for your app (or Docker Compose)
  • A deploy user with SSH access (ideally limited permissions)

GitHub secrets you’ll add in the repo settings:

  • DEPLOY_HOST (e.g., 203.0.113.10)
  • DEPLOY_USER (e.g., deploy)
  • DEPLOY_SSH_KEY (private key with access to that server)
  • APP_PORT (optional)

Add this job:

 deploy: name: Deploy to Server runs-on: ubuntu-latest needs: docker if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - name: Deploy via SSH uses: appleboy/[email protected] with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} script: | set -euo pipefail IMAGE="ghcr.io/${{ github.repository }}:latest" CONTAINER="webapp" echo "Pulling $IMAGE" docker pull "$IMAGE" echo "Stopping old container (if any)" docker rm -f "$CONTAINER" || true echo "Starting new container" docker run -d \ --name "$CONTAINER" \ --restart unless-stopped \ -p 8080:8080 \ -e NODE_ENV=production \ "$IMAGE" echo "Deployment done"

This is intentionally straightforward. Later, you can swap docker run for docker compose, add migrations, or integrate a real orchestrator.

Step 4: Make It Safer with Health Checks and Rollback-Friendly Tags

The fastest way to avoid “deployed but broken” is to:

  • Deploy using the sha tag (immutable)
  • Wait for a health check before declaring success

First, adjust the deploy script to use the commit SHA tag:

IMAGE="ghcr.io/${{ github.repository }}:sha-${{ github.sha }}"

Then add a basic HTTP health check. If your app exposes /health, you can verify it after starting the container:

docker run -d \ --name "$CONTAINER" \ --restart unless-stopped \ -p 8080:8080 \ "$IMAGE" echo "Waiting for health check..." for i in {1..30}; do if curl -fsS http://localhost:8080/health > /dev/null; then echo "Healthy!" exit 0 fi sleep 2 done echo "Health check failed" docker logs "$CONTAINER" --tail 200 exit 1

If you deploy by SHA, rollback becomes trivial: redeploy an earlier SHA tag.

Step 5: Add Environments (Staging vs Production)

A professional pipeline usually deploys to staging automatically and production with an approval step. GitHub Actions has “Environments” that can require manual approvals.

  • Create environments in GitHub repo settings: staging and production
  • Put different secrets in each (staging host, prod host, etc.)
  • Require approval for production

Then you can split deployment jobs like:

 deploy_staging: name: Deploy (Staging) runs-on: ubuntu-latest needs: docker environment: staging if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: # same SSH deploy, but uses staging secrets deploy_production: name: Deploy (Production) runs-on: ubuntu-latest needs: deploy_staging environment: production if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: # same SSH deploy, but uses production secrets

With production approvals enabled, GitHub will pause before deploying to prod. That’s a big safety win with minimal complexity.

Common Pitfalls (And How to Avoid Them)

  • Tests pass locally but fail in CI: lock your runtime versions. Use actions/setup-node with a specific version (like 20) and use npm ci.

  • Slow pipelines: enable caching (we used cache: "npm" and Docker layer caching with type=gha).

  • Secrets accidentally printed: never echo secrets in scripts. Prefer environment variables and carefully check logs.

  • “latest” tag confusion: use SHA tags for deployments. Keep latest for convenience, but don’t rely on it for traceability.

  • Deploy breaks active users: consider running two containers (blue/green) or using a reverse proxy (Nginx/Caddy) for zero-downtime reloads once your app grows.

A Minimal Checklist You Can Copy Into Real Projects

  • CI: lint + test on PRs
  • Build: Docker image pushed on merges to main
  • Deploy: staging auto-deploy, production gated by approval
  • Tags: deploy immutable SHA tags
  • Validation: health checks after deploy
  • Speed: caching for dependencies and Docker layers

Once this is in place, your team’s default behavior becomes: open PR → get feedback from automation → merge → ship. That’s the real power of CI/CD: not fancy YAML, but fewer “surprises” and more confidence with every change.


Leave a Reply

Your email address will not be published. Required fields are marked *