CI/CD Pipelines in Practice: Add PR Checks + Safe Deploys with GitHub Actions (Hands-On)

CI/CD Pipelines in Practice: Add PR Checks + Safe Deploys with GitHub Actions (Hands-On)

If you’re shipping a web app, a CI/CD pipeline is your safety net: it runs fast checks on every pull request (CI) and deploys reliably when changes merge (CD). In this hands-on guide, you’ll build a practical pipeline with GitHub Actions that:

  • Runs lint + tests on every push and pull request
  • Caches dependencies for speed
  • Builds a Docker image and pushes it to a registry
  • Deploys only from main with a manual “production” gate
  • Prevents common foot-guns (missing env vars, secret leaks, broken migrations)

The examples assume a typical Node.js app, but the structure works for Python, PHP, or any backend with minor tweaks.

What we’re building

We’ll create two workflows:

  • ci.yml: PR checks (lint, tests, build)
  • deploy.yml: build + push Docker image, then deploy (only on main)

We’ll also add a tiny script to fail fast when required environment variables are missing.

Repo setup

Your repository should have:

  • package.json with scripts: lint, test, build
  • A Dockerfile (example below)
  • A deployment target (we’ll show an SSH-based deploy pattern that works for a VM)

Example scripts in package.json:

{ "scripts": { "lint": "eslint .", "test": "jest", "build": "tsc -p tsconfig.json" } }

Step 1: A CI workflow for PR checks

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 -- --ci - name: Build run: npm run build

Why this works well:

  • npm ci installs from lockfile, making builds deterministic.
  • actions/setup-node caches ~/.npm automatically when cache: npm is set.
  • Running on PRs ensures broken code doesn’t land on main.

Step 2: Fail fast on missing environment variables

One of the most common “it worked locally” failures is missing env vars. Add a script that checks required variables in CI before a deploy. Create scripts/check-env.mjs:

const required = [ "NODE_ENV", "DATABASE_URL", "JWT_SECRET" ]; const missing = required.filter((k) => !process.env[k] || process.env[k].trim() === ""); if (missing.length) { console.error("Missing required environment variables:"); for (const k of missing) console.error(`- ${k}`); process.exit(1); } console.log("All required environment variables are set.");

You can run it in workflows like:

node scripts/check-env.mjs

In PR CI you may not want to require production secrets, so we’ll use this check only in the deploy workflow.

Step 3: Add a production-ready Dockerfile

This example builds and runs a Node app using multi-stage builds (fast, smaller images). Save as Dockerfile:

# syntax=docker/dockerfile:1 FROM node:20-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-alpine AS runtime WORKDIR /app ENV NODE_ENV=production # Only copy what we need at runtime COPY --from=build /app/package*.json ./ COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/dist ./dist EXPOSE 3000 CMD ["node", "dist/index.js"]

If you’re using Next.js or a different build output, adjust the copied paths accordingly.

Step 4: Build & push Docker images on main

Now create .github/workflows/deploy.yml. This workflow will:

  • Run only on pushes to main
  • Build + push to GitHub Container Registry (GHCR)
  • Deploy via SSH to a VM
  • Require a manual “production” environment approval (optional but recommended)
name: Deploy on: push: branches: ["main"] permissions: contents: read packages: write concurrency: group: production cancel-in-progress: false jobs: build-and-push: runs-on: ubuntu-latest outputs: image: ${{ steps.meta.outputs.image }} 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: Build app run: npm run build - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker metadata id: meta run: | IMAGE="ghcr.io/${{ github.repository }}/app" TAG="${{ github.sha }}" echo "image=${IMAGE}:${TAG}" >> $GITHUB_OUTPUT - name: Build and push run: | docker build -t "${{ steps.meta.outputs.image }}" . docker push "${{ steps.meta.outputs.image }}" deploy: needs: build-and-push runs-on: ubuntu-latest # Create an Environment named "production" in GitHub repo settings # and require reviewers to approve deployments. environment: production steps: - name: Check required env vars (deployment) env: NODE_ENV: production DATABASE_URL: ${{ secrets.DATABASE_URL }} JWT_SECRET: ${{ secrets.JWT_SECRET }} run: node -e "console.log('env ok')" # placeholder for optional check script - name: Deploy on VM via SSH uses: appleboy/[email protected] with: host: ${{ secrets.VM_HOST }} username: ${{ secrets.VM_USER }} key: ${{ secrets.VM_SSH_KEY }} script: | set -euo pipefail IMAGE="${{ needs.build-and-push.outputs.image }}" echo "Logging into GHCR..." echo "${{ secrets.GHCR_PAT }}" | docker login ghcr.io -u "${{ secrets.GHCR_USER }}" --password-stdin echo "Pulling new image: $IMAGE" docker pull "$IMAGE" echo "Stopping old container..." docker stop webapp || true docker rm webapp || true echo "Starting new container..." docker run -d --name webapp \ -p 80:3000 \ -e NODE_ENV=production \ -e DATABASE_URL="${{ secrets.DATABASE_URL }}" \ -e JWT_SECRET="${{ secrets.JWT_SECRET }}" \ --restart unless-stopped \ "$IMAGE" echo "Cleanup old images..." docker image prune -f

Notes on secrets:

  • GITHUB_TOKEN can push to GHCR for the same repo, but for pulling on a VM you usually use a PAT with read:packages.
  • Store the VM SSH key and credentials in Settings → Secrets and variables → Actions.

About the environment gate: create a GitHub “Environment” called production and enable required reviewers. That way, every deploy requires a human click approval even after code merges.

Step 5: Make deploys safer (health check + rollback tag)

A practical improvement is to verify the container is healthy before declaring success. Two simple tactics:

  • Add a /health endpoint to your app and curl it after starting the container.
  • Tag “latest known good” and use it for quick rollback.

Example: add a health check step to your SSH script:

echo "Waiting for health..." for i in $(seq 1 20); do if curl -fsS http://localhost/health > /dev/null; then echo "Healthy!" exit 0 fi sleep 1 done echo "Health check failed" docker logs webapp --tail 200 exit 1

For rollback, you can also push a second tag (like prod) only after the health check succeeds, then deploy by that tag. This pattern keeps “what is running” stable even if new builds are broken.

Step 6: Common CI/CD pitfalls (and how this pipeline avoids them)

  • Flaky builds: use npm ci and lockfiles; cache dependencies.
  • Accidental deploys from feature branches: deploy workflow triggers only on main.
  • Concurrent deploys stepping on each other: concurrency: production serializes deployments.
  • Secrets in logs: never echo secrets; pass via env vars; keep scripts strict with set -euo pipefail.
  • “It deployed but doesn’t work”: add a health check and fail the job if it’s unhealthy.

Step 7: Quick upgrades you can add next

Once the basics are working, these are high-impact improvements:

  • Preview deployments for PRs: deploy each PR to a temporary environment for QA.
  • DB migrations step: run migrations before starting the new container (and make it idempotent).
  • Security scanning: add dependency and container scans (SCA/SAST) as a separate job.
  • Slack/Teams notifications: notify on deploy start/success/failure.

Final checklist (copy/paste)

  • Create .github/workflows/ci.yml (lint/test/build on PR)
  • Create a Dockerfile and confirm it builds locally
  • Create .github/workflows/deploy.yml (build+push+deploy on main)
  • Add repo secrets: VM_HOST, VM_USER, VM_SSH_KEY, GHCR_USER, GHCR_PAT, DATABASE_URL, JWT_SECRET
  • Create a GitHub Environment production with required reviewers
  • Add a /health endpoint and curl it during deploy

With this setup, your PRs get fast feedback, and production deploys become boring (in the best way): predictable, review-gated, and easy to troubleshoot.


Leave a Reply

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