Skip to content
KheAi
Go back

Git Commit Standards & CI/CD Pipeline

Edit page

What This Part Covers

With all features built (Parts 1–18), Part 19 covers the professional development workflow that keeps the codebase maintainable at team scale. These practices apply from your very first commit, but they matter most when you have a full-featured backend and a team shipping to production.


Meteor Equivalent

Meteor’s deployment story was Galaxy — a proprietary PaaS. It handled containerization but limited control. Here you own the full pipeline.

ConcernMeteor/GalaxyEnterprise NestJS
Commit message formatAd hocConventional commits (auto-changelog)
Buildmeteor deployDocker multi-stage build
HostingGalaxyAWS ECS Fargate / Tencent TKE
Migrationsaldeed:migrations runs on startupOne-off ECS task BEFORE rolling deploy
SecretsMeteor settings / env varsAWS Secrets Manager (never in code)
CIOften manualGitHub Actions on every push

1. Conventional Commits + Commitizen

Part 02 covers workspace creation, initial Commitizen setup, and the VS Code Source Control workflow. This section assumes that setup is complete and goes deeper on automation (Husky hooks), branch protection, and CI/CD. ← Environment Setup & Nx Workspace

Why Conventional Commits

A consistent commit message format enables:

Format:

<type>(<scope>): <short description>

[optional body]

[optional footer]
TypeWhen to useExample
featNew feature or modulefeat(auth): add refresh token rotation
fixBug correctionfix(user): resolve null pointer on profile load
refactorCode restructure, no behavior changerefactor(payment): extract stripe logic to service
perfPerformance improvementperf(query): add index on tenantId column
testAdding or fixing teststest(tag): add E2E tests for create and delete
docsDocumentation onlydocs(readme): add docker setup steps
styleFormatting, whitespace, lintstyle(lint): enforce single quotes across app
buildDependencies or build toolingbuild(deps): upgrade @nestjs/graphql to 13.1.0
ciCI/CD configurationci(github): add E2E test step on pull request
choreRoutine upkeep that fits nowhere elsechore(env): create .env.example template
revertRolling back a commitrevert: feat(auth): add refresh token rotation

Scopes

The scope answers where — keep it lowercase, one word or hyphenated. Quickest rule: match the folder name. Editing src/modules/payment/ → scope is payment.

Backend

ScopeUse for
appmain.ts, app.module.ts, root-level app wiring
authLogin, JWT, guards, strategies, 2FA
userUser entity, profile, account management
dbMigrations, seeders, TypeORM config
coreShared abstract classes, global utilities
apiGraphQL resolvers, REST controllers
queueBull processors, job definitions
configNestJS ConfigModule, env schema

Infra / tooling

ScopeUse for
depsAdding / removing / upgrading packages
dockerDockerfiles, docker-compose.yml
scriptspackage.json script additions
repo.gitignore, .editorconfig, root config files
env.env, .env.example
lintESLint, Prettier config
ciGitHub Actions, CI pipeline YAML

Frontend (apps/web)

ScopeUse for
uiReusable components (buttons, modals, inputs)
layoutPage shell, header, sidebar, footer
pagesRoute-level page components
stylesGlobal CSS, Tailwind theme
storeState management (Zustand, Context, Apollo cache)

Examples:

feat(todo): add priority field to Todo entity
fix(auth): prevent timing attack on signIn
test(tag): add E2E tests for create and delete
chore(deps): upgrade @nestjs/graphql to 13.1.0

Install Commitizen

yarn add -D commitizen cz-conventional-changelog
// package.json
{
  "scripts": {
    "cz": "cz"
  },
  "config": {
    "commitizen": {
      "path": "cz-conventional-changelog"
    }
  }
}

The Correct Commit Workflow: Stage → Verify → cz

yarn cz formats the commit message. It does not decide what to stage. Staging is your responsibility — and it’s where the most common mistakes happen.

Never do this:

git add .     # stages everything: .env, debug logs, half-finished files, unrelated changes
yarn cz

Do this instead:

# Stage by module — name exactly what you changed
git add apps/api/src/modules/product/
git add apps/api/src/migrations/*product*
git add apps/api/src/app/app.module.ts

# Verify what will actually be in the commit
git diff --staged

# Only then open the commit prompt
yarn cz

Why this matters:

Internalize: stage → verify → cz. The git add . shortcut is fine for personal scripts and throwaway projects. Not here.

VS Code Source Control: The Right Visual Tool

yarn cz runs in the terminal. It asks the core Git engine whether the staging index has any files. Standalone GUIs (like GitHub Desktop) hold their checkmarks in internal memory and only run git add a millisecond before their own commit button — so the terminal always sees an empty index.

VS Code’s built-in Source Control writes to the real index on every click. Use it instead:

ActionMacWindows/Linux
Open Source Control tabCmd+Shift+GCtrl+Shift+G
Toggle integrated terminal`Cmd+“`Ctrl+“
Stage specific lines onlyHighlight in diff → right-click → Stage Selected Rangessame

Stage Selected Ranges is the practical superpower here. If a file has both the bug fix you want to commit and a debug log you don’t, highlight only the fix lines in the diff editor and stage exactly those. Cleaner commits, no git stash gymnastics.


2. Husky Hooks

Husky runs scripts before git events. Two hooks catch problems before they reach the remote:

  1. pre-commit: lint staged files (runs first)
  2. commit-msg: enforce conventional commit format (runs after pre-commit passes)

If lint fails, the commit-msg check never runs. Fix lint errors before worrying about the message format.

yarn add -D husky lint-staged @commitlint/cli @commitlint/config-conventional
npx husky init

pre-commit hook

# .husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx lint-staged
// package.json
{
  "lint-staged": {
    "apps/**/*.ts": ["eslint --fix", "git add"],
    "libs/**/*.ts": ["eslint --fix", "git add"]
  }
}

commit-msg hook

# .husky/commit-msg
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx --no -- commitlint --edit "$1"
// commitlint.config.js
module.exports = { extends: ['@commitlint/config-conventional'] };

A message like "fixed stuff" is rejected at the commit-msg stage:

✖ subject may not be empty [subject-empty]
✖ type may not be empty [type-empty]
✖ found 1 problems, 0 warnings

3. Branch Strategy

main             ──────────────●──────────────●──────────────→
                               ↑ squash       ↑ squash
feat/add-tag     ──●──●──●────╯              |
                                             |
fix/auth-timing           ──●──●──●──●──────╯

Rules:

PR Template (add to .github/PULL_REQUEST_TEMPLATE.md)

## Summary
- What was added/changed
- Why (link to ticket)

## Migration
- Tables added/changed:
- Run + revert both tested locally: yes / no

## Testing
- Unit: (test file names + count)
- E2E: (test file + operations covered)

## Checklist
- [ ] Migration generated and reviewed (SQL reviewed, not just generated)
- [ ] `migration:run` tested locally
- [ ] `migration:revert` tested locally
- [ ] Unit tests added/updated
- [ ] E2E tests pass locally (`yarn api:e2e`)
- [ ] No `synchronize: true` left in TypeORM config
- [ ] No `console.log` left in production code
- [ ] `@UseGuards(AuthJwtGuard)` on all new mutations/queries that need auth
- [ ] `tenantId` FK present on any new domain entity
- [ ] impact analysis run for any symbol changes (`gitnexus impact`)

4. GitHub Branch Protection Rules

Set these up once per repository. They enforce the branch strategy at the platform level — no individual can bypass them, including admins, unless the rules are explicitly disabled.

GitHub repo → Settings → Branches → Add branch ruleset

Branch name pattern: main
[✅] Require a pull request before merging
  [✅] Require approvals: 1
  [✅] Dismiss stale pull request approvals when new commits are pushed

[✅] Require status checks to pass before merging
  [✅] Require branches to be up to date before merging
  Status checks: lint, unit-test, e2e-test

[✅] Require conversation resolution before merging
[✅] Delete head branches automatically
[✅] Do not allow bypassing the above settings

The last rule is the important one. Without it, the repo owner can force-push over everything. If you ever need to hotfix directly on main, temporarily disable it, apply the fix, then re-enable it immediately. Treating that process as the rare exception keeps the protection meaningful.


5. Dockerfile — Multi-Stage Build

A multi-stage build keeps the final image small — no devDependencies, no TypeScript compiler.

# Dockerfile
# ── Stage 1: Build ─────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app

COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

COPY . .
RUN yarn nx build api --prod

# ── Stage 2: Production deps only ─────────────────────────────
FROM node:20-alpine AS deps-prod
WORKDIR /app

COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile --production

# ── Stage 3: Runtime ───────────────────────────────────────────
FROM node:20-alpine AS runtime
WORKDIR /app

RUN addgroup -S app && adduser -S app -G app

COPY --from=deps-prod /app/node_modules ./node_modules
COPY --from=builder /app/dist/apps/api ./dist

USER app

EXPOSE 3000
CMD ["node", "dist/main.js"]

Build and test locally:

docker build -t enterprise-todo-api .
docker run -p 3000:3000 --env-file .env enterprise-todo-api

6. Local Docker Infrastructure

docker-compose.dev.yml is defined in Part 02 §5. That file covers PostgreSQL, Redis, and Adminer with named volumes and convenience scripts (yarn docker:dev). ← Environment Setup & Nx Workspace

For CI, the services are declared inline in the GitHub Actions workflow (see §7 below) — no compose file is used in CI runners.


7. GitHub Actions CI Pipeline

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

env:
  NODE_VERSION: '20'

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'yarn'
      - run: yarn install --frozen-lockfile
      - run: yarn lint

  unit-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'yarn'
      - run: yarn install --frozen-lockfile
      - run: yarn api:test --coverage
      - uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/

  e2e-test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: enterprise_todo_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 5

    env:
      NODE_ENV: test
      DB_HOST: localhost
      DB_PORT: 5432
      DB_USERNAME: test
      DB_PASSWORD: test
      DB_DATABASE: enterprise_todo_test
      REDIS_HOST: localhost
      REDIS_PORT: 6379
      JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY_TEST }}
      JWT_PUBLIC_KEY: ${{ secrets.JWT_PUBLIC_KEY_TEST }}

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'yarn'
      - run: yarn install --frozen-lockfile
      - name: Run migrations
        run: yarn migration:run
      - run: yarn api:e2e

  build:
    runs-on: ubuntu-latest
    needs: [lint, unit-test, e2e-test]
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-southeast-1
      - name: Login to ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2
      - name: Build and push Docker image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/enterprise-todo-api:$IMAGE_TAG .
          docker push $ECR_REGISTRY/enterprise-todo-api:$IMAGE_TAG
      - name: Deploy to ECS
        run: |
          aws ecs update-service \
            --cluster enterprise-todo-prod \
            --service api-service \
            --force-new-deployment

8. Production Migrations — One-Off ECS Task

Never run migrations inside the API startup. The pattern:

1. Build new Docker image (SHA-tagged)
2. Run migration as a one-off ECS task (using the new image)
   → Hits the PRODUCTION database
   → Exits: code 0 = success | code 1 = failure + rollback trigger
3. If success → update ECS service to new image
4. ECS rolling deploy: new tasks start, old tasks drain
5. If failure → stop deploy, run migration:revert, investigate

Why not on startup:

The migration is the renovation crew, not the building manager. You wouldn’t ask your building manager to renovate the lobby every morning when opening the building — especially with tenants already inside. You schedule the renovation crew for a specific time slot, with no tenants present, and let the manager open up only after the crew has signed off. The ECS one-off migration task is the renovation crew: it runs once, explicitly, before any new API pod starts serving traffic.

// apps/api/src/migrate.ts — separate entry point for migration ECS task
import { DataSource } from 'typeorm';
import { dataSourceOptions } from './ormconfig';

async function runMigrations() {
  const dataSource = new DataSource(dataSourceOptions);
  await dataSource.initialize();
  await dataSource.runMigrations();
  await dataSource.destroy();
  process.exit(0);
}

runMigrations().catch((error) => {
  console.error('Migration failed:', error);
  process.exit(1);
});

ECS task definition uses CMD ["node", "dist/migrate.js"] — a separate task definition from the API service.


9. Secrets in Production

EnvironmentStorage
Local dev.env (gitignored)
CIGitHub Actions secrets
Staging/ProductionAWS Secrets Manager
// apps/api/src/config/secrets.ts
import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager';

const client = new SecretsManagerClient({ region: 'ap-southeast-1' });

export async function loadSecrets(): Promise<Record<string, string>> {
  const { SecretString } = await client.send(
    new GetSecretValueCommand({ SecretId: 'enterprise-todo/prod' }),
  );
  return JSON.parse(SecretString!);
}
// apps/api/src/main.ts
if (process.env.NODE_ENV === 'production') {
  const secrets = await loadSecrets();
  Object.assign(process.env, secrets);
}
const app = await NestFactory.create(AppModule);

What goes in Secrets Manager:

Never commit these to git — even in a private repo. A repo that becomes public, a leaked deploy key, or a compromised team member account all become incidents if secrets are in the codebase.


10. What Happens When You Push to Main

git push origin feature/add-tag ──→ open PR


                               GitHub Actions CI
                               ├── lint ✓
                               ├── unit tests ✓
                               └── E2E tests ✓

                          PR approved + squash merge to main


                               GitHub Actions CD
                               ├── docker build (SHA-tagged)
                               ├── push to ECR
                               ├── run migration task (ECS one-off)
                               └── update ECS service (rolling deploy)


                               Production live ✓

Part 20 — Production Deployment covers deploying both NestJS apps to AWS ECS Fargate with RDS, ElastiCache, and a zero-downtime migration strategy using the one-off ECS task pattern introduced here.


Quick Reference

ActionTypeScopeExample
Add new NestJS modulefeatmodule namefeat(agent): scaffold agent module with CQRS
Fix broken DI / importfixappfix(app): resolve circular dependency in auth module
Add database migrationfeatdbfeat(db): add tenantId to agent table
Install packagesbuilddepsbuild(deps): add @nestjs/bull and bull
Add Docker compose filechoredockerchore(docker): add dev compose for postgres and redis
Add env varchoreenvchore(env): add REDIS_BULL_HOST to env example
Add convenience scriptchorescriptschore(scripts): add backend:dev shortcut
Clean up unused importsrefactormodule namerefactor(auth): remove unused passport imports
Add unit teststestmodule nametest(user): add unit tests for update handler
Update GitHub Actionscigithubci(github): add e2e test job on pull request

Edit page
Share this post:

Next Post
Production Deployment — ECS Fargate, RDS, ElastiCache & Zero-Downtime Releases
Previous Post
Affiliate & Referral Tree — Materialized Path Hierarchy on UserEntity