← All articles
June 7, 2026Dev Tools

CI/CD Pipeline for a Next.js Portfolio — From Lint to Production Deploy

How I built a full GitHub Actions pipeline that runs lint, type check, unit tests with coverage, Docker smoke test, and SSH deploy — all on every push to main.

cigithub-actionsdockernextjsdevopsssh
Share

Overview

The CI/CD pipeline for kmoussouni.dev runs on GitHub Actions and handles everything from code quality checks to production deployment on an OVH server. On every push to main, the pipeline:

  1. Installs dependencies (npm ci)
  2. Runs ESLint
  3. Type-checks with tsc --noEmit
  4. Runs 78 unit tests with coverage thresholds
  5. Builds the Next.js production bundle
  6. Builds and smoke-tests the Docker image
  7. Deploys to production via SSH

Pipeline Structure

jobs:
  build:       # Runs on every push + PR to main/develop
  smoke-test:  # main only — builds Docker image, hits health endpoint
  deploy-prod: # main only — SSH deploy

The smoke-test and deploy-prod jobs are gated with if: github.ref == 'refs/heads/main'. Pull requests on develop only run build, keeping CI fast during development (~2 minutes vs ~8 minutes for the full pipeline).

Dependency Management Gotcha

A subtle issue: some packages (@testing-library/react, @vitejs/plugin-react, jsdom) were installed locally as transitive dependencies but not declared in devDependencies. Tests passed locally, but CI failed with Cannot find module errors because npm ci is strict about the lock file.

The fix: explicitly declare every package your tests and config files import directly.

"devDependencies": {
  "@testing-library/react": "^16.3.2",
  "@vitejs/plugin-react": "^5.1.4",
  "jsdom": "^26.1.0"
}

Then regenerate the lock file with npm install (not npm install --legacy-peer-deps, which can silently drop other transitive dependencies).

Smoke Test: Docker Health Check

The smoke test builds the production Docker image and starts a container, then polls the /api/health endpoint until it responds:

docker run -d --name km-hub-test -p 3000:3000 \
  -e NEXTAUTH_URL=http://localhost:3000 \
  -e NEXTAUTH_SECRET=ci-smoke-test \
  km-hub-test

for i in $(seq 1 30); do
  if curl -sf http://localhost:3000 > /dev/null 2>&1; then
    echo "Health check passed after ${i}s"
    exit 0
  fi
  sleep 1
done

It also verifies security headers: X-Powered-By must be absent, CSP and Permissions-Policy must be present.

SSH Deploy

Production deployment is a single deploy.sh script triggered over SSH:

- name: Deploy via SSH
  uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.SERVER_HOST }}
    username: deploy
    key: ${{ secrets.SERVER_SSH_KEY }}
    script: cd /opt/km-hub && bash scripts/deploy.sh

The script:

  1. git pull origin main
  2. docker compose build --no-cache km-hub
  3. docker compose up -d
  4. Removes stray containers (Docker bypasses UFW — any rogue container binding 0.0.0.0 is publicly exposed)
  5. Health check loop (60s timeout)
  6. docker image prune -f

The Stray Container Guard

Docker bypasses UFW firewall rules by default. A container binding 0.0.0.0:8025 is reachable from the internet regardless of UFW rules. The deploy script enforces an allowlist:

ALLOWED_RE="^(km-hub|caddy|umami|postgres)$"
STRAY=$(docker ps --format '{{.Names}}' | grep -Ev "$ALLOWED_RE" || true)
if [ -n "$STRAY" ]; then
    echo "$STRAY" | xargs -r docker stop
    echo "$STRAY" | xargs -r docker rm
fi

This was added after an incident where a Mailpit container (local mail catcher) was accidentally left running in production, exposing port 8025 publicly.

Local Changes Blocking Deploy

A recurring issue: the Caddy container mounts Caddyfile as a read-only volume from the host. If someone edits Caddyfile directly on the server (e.g. for a hotfix), git pull fails with "local changes would be overwritten".

The fix before running deploy.sh:

git checkout -- Caddyfile docker-compose.prod.yml

Or in the deploy script itself, a git stash before git pull would handle this automatically.

Results

The full pipeline (lint → tsc → tests → build → Docker smoke → SSH deploy) runs in under 8 minutes. PRs on develop get a 2-minute quality gate. Production is only touched after a clean run of all 3 jobs.