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
mainwith 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 onmain)
We’ll also add a tiny script to fail fast when required environment variables are missing.
Repo setup
Your repository should have:
package.jsonwith 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 ciinstalls from lockfile, making builds deterministic.actions/setup-nodecaches~/.npmautomatically whencache: npmis 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_TOKENcan push to GHCR for the same repo, but for pulling on a VM you usually use a PAT withread: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
/healthendpoint 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 ciand lockfiles; cache dependencies. - Accidental deploys from feature branches: deploy workflow triggers only on
main. - Concurrent deploys stepping on each other:
concurrency: productionserializes deployments. - Secrets in logs: never
echosecrets; pass via env vars; keep scripts strict withset -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
productionwith required reviewers - Add a
/healthendpoint 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