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 imagesdocker/build-push-actionfor multi-step Docker builds + cachingsshdeployment 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.ymlpresent- Permissions so the SSH user can run Docker (commonly by being in the
dockergroup)
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
mainto staging, deploy tags (likev1.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 testlocally, ensure your lockfile is committed. - Build works locally but fails in CI: check your
Dockerfilecopy 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 yourCMDandPORTmatch.
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