Skip to content
KheAi
Go back

Git Workflow, CI/CD & Deployment

Edit page

What This Part Covers


Meteor Equivalent

Meteor’s deployment story was Galaxy — a proprietary PaaS. It handled containerization for you but limited control. Here you own the full deployment 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

Why Conventional Commits

A consistent commit message format enables:

Format:

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

[optional body]

[optional footer]

Types: feat, fix, docs, style, refactor, perf, test, chore, ci

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": "./node_modules/cz-conventional-changelog"
    }
  }
}

Now yarn cz opens an interactive prompt that formats the message correctly.


2. Husky Hooks

Husky runs scripts before git events (commit, push). Two critical hooks:

  1. pre-commit: run lint on staged files
  2. commit-msg: enforce conventional commit 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'] };

Now a commit message like "fixed stuff" will be rejected:

✖ 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
feature/add-tag  ──●──●──●────╯              |
                                             |
feature/auth-2fa          ──●──●──●──●──────╯

Rules:

PR Checklist (add to PULL_REQUEST_TEMPLATE.md)

## PR Checklist

- [ ] Migration generated and reviewed (`migration:generate`, reviewed SQL)
- [ ] `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

4. Dockerfile — Multi-Stage Build

A multi-stage Docker 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 . .

# Build the NestJS API
RUN yarn nx build api --prod

# ── Stage 2: Prune dev deps ────────────────────────────────────
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

# Non-root user for security
RUN addgroup -S app && adduser -S app -G app

# Copy only production dependencies
COPY --from=deps-prod /app/node_modules ./node_modules
# Copy the compiled output
COPY --from=builder /app/dist/apps/api ./dist

USER app

EXPOSE 3000

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

Build and run locally:

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

5. docker-compose.dev.yml (Full Version)

# docker-compose.dev.yml
version: '3.9'

services:
  postgres:
    image: postgres:15-alpine
    ports:
      - '5432:5432'
    environment:
      POSTGRES_USER: ${DB_USERNAME}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_DATABASE}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U ${DB_USERNAME}']
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - '6379:6379'
    volumes:
      - redis_data:/data
    healthcheck:
      test: ['CMD', 'redis-cli', 'ping']
      interval: 5s
      timeout: 5s
      retries: 5

  adminer:
    image: adminer
    ports:
      - '8080:8080'
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  postgres_data:
  redis_data:

6. 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: |
          # Update the ECS task definition with the new image tag
          # then update the service to force a new deployment
          aws ecs update-service \
            --cluster enterprise-todo-prod \
            --service api-service \
            --force-new-deployment

7. Production Migrations — One-Off ECS Task

Never run migrations as part of 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)
   → This hits the PRODUCTION database
   → Runs and exits (exit code 0 = success, exit code 1 = failure + rollback trigger)
3. If migration succeeds → update ECS service to new image
4. ECS rolling deploy: new tasks start, old tasks drain
5. If migration fails → stop deploy, run migration:revert

Why not on startup?

Separate migration entrypoint:

// apps/api/src/migrate.ts — separate entry point for migration 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);
});

Run as an ECS task with CMD ["node", "dist/migrate.js"].


8. Secrets in Production

EnvironmentSecrets storage
Local dev.env file (gitignored)
CIGitHub Actions secrets
Staging/ProductionAWS Secrets Manager

AWS Secrets Manager pattern:

// apps/api/src/config/secrets.ts — load from AWS at startup
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!);
}

Inject in main.ts before bootstrap if running in production:

if (process.env.NODE_ENV === 'production') {
  const secrets = await loadSecrets();
  Object.assign(process.env, secrets);
}
const app = await NestFactory.create(AppModule);

What goes into Secrets Manager:

Never commit these to git, even in a private repo.


9. Summary — 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
                               ├── push to ECR
                               ├── run migration task (ECS one-off)
                               └── update ECS service (rolling deploy)


                               Production live ✓

Edit page
Share this post:

Next Post
Multi-tenancy & Role-Based Access Control (RBAC)
Previous Post
Queues & Real-time