What “done” looks like for a Node pipeline
On pull requests: install, lint, typecheck, unit tests. On main: same checks, then build, then deploy to staging or production with an explicit environment approval if the blast radius is high. Artifacts should be reproducible—same commit, same build output.
I used to deploy from a developer laptop with a checklist in Notion. That “process” shipped a debug flag to production. CI is not bureaucracy; it is how I stop trusting my memory at 11pm.
Done also means failed checks block merges. A yellow optional workflow that nobody watches is decoration. Protect the main branch and require the CI job by name.
Preview environments for pull requests are optional sugar. Correct mainline CI is mandatory. I add preview apps only after the basic test/build/deploy path is boringly reliable.
I also require that package-lock.json is committed and that CI fails if the lockfile would change under npm ci. Drift between package.json and the lockfile is a silent portability bug.
A solid PR workflow
Use `npm ci` with a lockfile, not `npm install`. Cache the npm directory keyed on package-lock.json. Fail fast on Node version mismatch by pinning with actions/setup-node.
I run lint and typecheck before tests when they are cheap. If tests are the slow part, keep them parallel later. The goal is a clear signal in under a few minutes for typical PRs.
Commit the Node version in .nvmrc and engines in package.json so local, CI, and production agree. Version drift caused a native module failure that only appeared on the server image.
For monorepos, scope workflows with path filters so a docs-only change does not rebuild every package. Broad workflows feel thorough and train people to ignore forty-minute waits.
name: ci
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test -- --coverage
Build and deploy without mystery meat
Separate jobs: test → build → deploy. Pass the build as an artifact or rebuild in deploy with the same commit SHA. I prefer building once and uploading `dist/` so production runs exactly what CI verified.
Secrets live in GitHub Environments. Production deploy needs a reviewer. SSH keys and cloud tokens never belong in the workflow file—yes, I have seen tokens committed “temporarily.”
For container deploys, push an image tagged with the git SHA, then deploy that digest. Latest tags are how we once rolled back to the wrong build during an incident.
When deploy needs a process restart, the workflow should call a script that reloads systemd or cycles containers healthily. Ad-hoc SSH commands in YAML become untestable folklore.
build:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist
- name: Rsync to server
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
HOST: ${{ secrets.DEPLOY_HOST }}
run: |
install -m 600 /dev/null ~/.ssh/id_ed25519
echo "$SSH_KEY" > ~/.ssh/id_ed25519
rsync -az --delete dist/ deploy@$HOST:/var/www/app/dist/
Mistakes that burned CI time and nerves
Running `npm install` without a lockfile made “green on CI, broken on server” a monthly event. Skipping `npm ci` in deploy and installing again with different optional dependencies did the same.
Another classic: tests that depend on a developer’s local `.env`. Inject test env in the workflow. And do not run deploys on every branch push—protect main and require PR checks.
Flaky tests taught the team to re-run until green. I quarantine flakes ruthlessly. A pipeline that lies is worse than no pipeline.
Concurrency groups cancel outdated runs on the same branch. Without them, an older slow workflow can deploy after a newer commit and roll you backward. I set concurrency per ref for deploy jobs.
- Pin Node version in setup-node and .nvmrc
- Cache dependencies; do not cache node_modules blindly across OS changes
- Use environments for production approvals
- Keep deploy scripts idempotent (rsync --delete, migrations once)
- Fail the job on soft warnings you actually care about (type errors, audit critical)
Migrations and zero-downtime reality
App deploys and DB migrations need an order. Expand-contract: add nullable columns first, deploy code that reads both shapes, then backfill, then enforce constraints. I burned a release by running a destructive migration in the same job as a rolling restart.
If migrations run in CI, use a dedicated step with locked credentials and a clear failure page. Do not hide `ALTER TABLE` inside the Node process boot—two instances booting will race.
For risky migrations, ship a feature flag that stops writes to the old path before you drop a column. CI cannot save you from a one-way data loss without a backup plan.
Backup before migrate. Even expand-only migrations deserve a snapshot on production the first few times a team adopts CI-driven schema changes. Trust is earned.
Signal over theater
A pipeline that takes 25 minutes teaches people to ignore it. Parallelize lint and tests when possible. Fail on the first meaningful error. Slack noise on every success is useless; alert on failed main deploys.
Start simple: one workflow file, two jobs, one environment. Add matrix builds and preview apps when the team feels the pain—not before.
I review workflow YAML in PRs the same way I review app code. Copy-pasted actions with unpinned versions are supply-chain debt. Prefer major-version tags you consciously upgrade.
Track mean time from merge to production. If it grows past what your product needs, simplify jobs before adding more checks. Throughput is a feature of the pipeline.
Publish a short CONTRIBUTING note that links the required checks. New contributors should not discover merge blockers by trial and error on their first PR.
Smoke tests after deploy
A green unit suite does not prove the site is up. I add a post-deploy step that curls /health and a critical HTML route, failing the job if status is not 200. That caught DNS and Nginx misconfigs unit tests never see.
For APIs, hit an authenticated smoke endpoint with a scoped CI token. Keep the token read-only. If smoke fails, page a human before customers do.
- name: Smoke check
run: |
curl -fsS "https://example.com/health" | grep -q '"ok":true'
curl -fsSI "https://example.com/" | head -n1 | grep -q 200
Key takeaways
- npm ci + lockfile + pinned Node version is non-negotiable
- Separate test, build, and deploy jobs
- Store deploy secrets in GitHub Environments
- Build once; deploy the same artifact when possible
- Keep pipelines fast enough that people trust them
About the author
Ram — Founder & Editor, BudhiWorks. I build and ship production web apps — Node, React/Next.js, Postgres, and the boring infrastructure that keeps them online. BudhiWorks is where I publish the guides I wish I had when something broke at 2 a.m.