CI/CD Pipelines in Practice: A GitHub Actions Workflow That Tests, Builds, Ships a Docker Image, and Deploys

CI/CD Pipelines in Practice: A GitHub Actions Workflow That Tests, Builds, Ships a Docker Image, and Deploys

If you’ve ever merged to main and then manually ran tests, built an image, and copy-pasted deployment commands… you’ve already felt why CI/CD exists. A good pipeline does four things consistently:

  • Validates every change (lint + tests)
  • Builds the artifact you actually run (Docker image)
  • Publishes it somewhere reliable (a container registry)
  • Deploys it safely (staging first, then production)

This article walks through a practical, junior-friendly setup using GitHub Actions for a containerized web app (Node.js example). You can adapt the same structure for Python, PHP, etc.

What We’re Building

We’ll create a pipeline with two jobs:

  • CI job (runs on every PR + on pushes): install deps, lint, test
  • CD job (runs on push to main): build Docker image, push to GHCR, deploy to a server via SSH

We’ll use:

  • GitHub Actions (workflow runner)
  • GHCR (GitHub Container Registry) for images
  • docker/build-push-action for multi-step Docker builds + caching
  • ssh deployment that pulls and restarts a container

Project Layout

Assume your repo looks like this:

. ├─ src/ ├─ package.json ├─ package-lock.json ├─ Dockerfile ├─ docker-compose.prod.yml └─ .github/ └─ workflows/ └─ ci-cd.yml 

And your app starts with npm run start and has npm test and npm run lint.

Step 1: Add a Production-Ready Dockerfile

This uses a multi-stage build: one stage installs dependencies, another runs the app. It keeps images smaller and faster to deploy.

# Dockerfile # --- dependencies stage --- FROM node:20-alpine AS deps WORKDIR /app COPY package*.json ./ RUN npm ci # --- runtime stage --- FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production # Copy only what we need COPY --from=deps /app/node_modules ./node_modules COPY . . # If you build a frontend bundle, do it in a build stage and copy /dist here. EXPOSE 3000 CMD ["npm", "run", "start"] 

Tip: If your app requires a build step (React/Vite/Next), add a build stage and copy the compiled output into the runtime stage. The pattern stays the same.

Step 2: Add a Simple Production Compose File

This is what your server will run. You can add databases, Redis, etc. later.

# docker-compose.prod.yml services: web: image: ghcr.io/YOUR_ORG_OR_USER/YOUR_REPO:latest container_name: web-app restart: unless-stopped ports: - "3000:3000" environment: - NODE_ENV=production 

Replace YOUR_ORG_OR_USER and YOUR_REPO after you know your image name (we’ll reference it in the workflow too).

Step 3: Create the GitHub Actions Workflow

Create .github/workflows/ci-cd.yml:

name: CI/CD on: pull_request: branches: ["main"] push: branches: ["main"] permissions: contents: read packages: write # required to push to GHCR env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} # e.g. org/repo jobs: ci: name: Lint + Test 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 - name: Test run: npm test cd: name: Build + Push + Deploy runs-on: ubuntu-latest needs: ci if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - name: Checkout uses: actions/checkout@v4 - name: Set up QEMU (optional, for multi-arch) uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract Docker metadata (tags, labels) id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=raw,value=latest type=sha - 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 - name: Deploy via SSH (pull + restart) uses: appleboy/[email protected] with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} script: | set -e docker login ghcr.io -u ${{ github.actor }} -p ${{ secrets.GITHUB_TOKEN }} docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest cd /opt/web-app docker compose -f docker-compose.prod.yml up -d docker image prune -f 

Step 4: Add Required Secrets

Go to your repo: Settings → Secrets and variables → Actions → New repository secret, and add:

  • DEPLOY_HOST (server IP or hostname)
  • DEPLOY_USER (SSH username)
  • DEPLOY_SSH_KEY (private key content)

On the server, set up:

  • Docker + Docker Compose installed
  • /opt/web-app/docker-compose.prod.yml present
  • Permissions so the SSH user can run Docker (commonly by being in the docker group)

Step 5: Make Deployment Safer with a Health Check

“Container started” doesn’t mean “app is actually working.” Add a healthcheck so Compose can report status.

# docker-compose.prod.yml (add under web:) healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"] interval: 10s timeout: 3s retries: 10 

Then add a simple endpoint in your app (example Express):

// src/server.js (Express example) import express from "express"; const app = express(); app.get("/health", (req, res) => { res.status(200).json({ ok: true }); }); app.listen(3000, () => console.log("Listening on 3000")); 

Now you can troubleshoot deploys with: docker ps, docker inspect, and docker logs web-app.

Common Improvements (That You’ll Actually Use)

  • Run CI on every branch: expand workflow triggers so feature branches get validation early.
  • Use staging and production: deploy main to staging, deploy tags (like v1.2.3) to production.
  • Add a manual approval step: GitHub Environments can require reviewers before a production deploy.
  • Pin action versions: for stricter reproducibility, pin to commit SHAs instead of @vX.
  • Store runtime secrets on the server: prefer server-side env files or secret managers over baking secrets into images.

Bonus: Deploy Only When Something Relevant Changes

If you want to skip Docker builds when docs change, add path filters:

on: push: branches: ["main"] paths: - "src/**" - "package.json" - "package-lock.json" - "Dockerfile" - ".github/workflows/**" 

This keeps your pipeline faster and reduces unnecessary deployments.

Troubleshooting Checklist

  • CI fails on tests: run npm test locally, ensure your lockfile is committed.
  • Build works locally but fails in CI: check your Dockerfile copy paths and missing files.
  • Push to GHCR denied: verify workflow permissions: packages: write.
  • SSH deploy fails: confirm the key has access, host is reachable, and the user can run Docker.
  • App starts then exits: check docker logs, confirm your CMD and PORT match.

Wrap-Up

You now have a practical CI/CD pipeline that junior/mid developers can maintain: it validates every PR, produces a real deployable artifact (a Docker image), pushes it to a registry, and deploys it to a server with a repeatable command. Once this is in place, you’ll spend less time “doing releases” and more time shipping features with confidence.


Leave a Reply

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