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.
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:
- Installs dependencies (
npm ci) - Runs ESLint
- Type-checks with
tsc --noEmit - Runs 78 unit tests with coverage thresholds
- Builds the Next.js production bundle
- Builds and smoke-tests the Docker image
- 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:
git pull origin maindocker compose build --no-cache km-hubdocker compose up -d- Removes stray containers (Docker bypasses UFW — any rogue container binding
0.0.0.0is publicly exposed) - Health check loop (60s timeout)
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.