Back to Technology

API Development Series Part 16: CI/CD & Automation

January 31, 2026 Wasil Zafar 40 min read

Master API CI/CD and automation including Spectral linting, GitHub Actions, GitLab CI, Blue/Green deployments, Canary releases, Terraform infrastructure, and automated API pipelines.

Table of Contents

  1. API Linting with Spectral
  2. GitHub Actions Pipelines
  3. GitLab CI/CD
  4. Blue/Green Deployments
  5. Canary Releases
  6. Terraform for APIs
Series Navigation: This is Part 16 of the 17-part API Development Series. Review Part 15: Testing & Contracts first.

API Linting with Spectral

What is Spectral?

Spectral is a flexible, JSON/YAML linter for OpenAPI specs. It enforces style guides and catches errors before they reach production.

# Install Spectral
npm install -g @stoplight/spectral-cli

# Lint OpenAPI spec
spectral lint openapi.yaml

# With custom ruleset
spectral lint openapi.yaml --ruleset .spectral.yaml

Custom Ruleset

# .spectral.yaml - Enterprise API standards
extends: spectral:oas

rules:
  # Require descriptions
  operation-description:
    description: Operations must have descriptions
    severity: warn
    given: "$.paths.*[get,post,put,patch,delete]"
    then:
      field: description
      function: truthy

  # Enforce naming conventions
  path-kebab-case:
    description: Paths must be kebab-case
    severity: error
    given: "$.paths"
    then:
      field: "@key"
      function: pattern
      functionOptions:
        match: "^(/[a-z0-9-{}]+)+$"

  # Require security on all operations
  operation-security:
    description: All operations must define security
    severity: error
    given: "$.paths.*[get,post,put,patch,delete]"
    then:
      field: security
      function: truthy

  # Response body required for 2xx
  response-body-required:
    description: 2xx responses must have content
    severity: warn
    given: "$.paths.*.*.responses[?(@property >= 200 && @property < 300)]"
    then:
      field: content
      function: truthy

  # Contact info required
  info-contact:
    description: API must have contact info
    severity: error
    given: "$.info"
    then:
      field: contact
      function: truthy

GitHub Actions Pipelines

Complete CI/CD Pipeline

# .github/workflows/api-pipeline.yml
name: API CI/CD Pipeline

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

env:
  NODE_VERSION: '20'
  AWS_REGION: us-east-1

jobs:
  lint:
    name: Lint & Validate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Lint OpenAPI spec
        run: npx spectral lint openapi.yaml --fail-severity=error
      
      - name: Lint code
        run: npm run lint

  test:
    name: Test
    runs-on: ubuntu-latest
    needs: lint
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: --health-cmd pg_isready --health-interval 10s
      redis:
        image: redis:7
        ports:
          - 6379:6379
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run migrations
        run: npm run db:migrate
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/testdb
      
      - name: Run unit tests
        run: npm run test:unit
      
      - name: Run integration tests
        run: npm run test:integration
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/testdb
          REDIS_URL: redis://localhost:6379
      
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info

  contract-test:
    name: Contract Tests
    runs-on: ubuntu-latest
    needs: lint
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run Pact provider verification
        run: npm run test:pact:provider
      
      - name: Publish Pact results
        run: npx pact-broker publish ./pacts --broker-base-url=${{ secrets.PACT_BROKER_URL }} --broker-token=${{ secrets.PACT_BROKER_TOKEN }}
        if: github.ref == 'refs/heads/main'

  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    needs: [test, contract-test]
    if: github.ref == 'refs/heads/develop'
    environment: staging
    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: ${{ env.AWS_REGION }}
      
      - name: Deploy to staging
        run: npm run deploy:staging

  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: [test, contract-test]
    if: github.ref == 'refs/heads/main'
    environment: production
    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: ${{ env.AWS_REGION }}
      
      - name: Deploy to production
        run: npm run deploy:production

GitLab CI/CD

# .gitlab-ci.yml
stages:
  - validate
  - test
  - build
  - deploy

variables:
  NODE_IMAGE: node:20-alpine
  DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

lint:
  stage: validate
  image: $NODE_IMAGE
  script:
    - npm ci
    - npx spectral lint openapi.yaml --fail-severity=error
    - npm run lint
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

test:
  stage: test
  image: $NODE_IMAGE
  services:
    - postgres:15
    - redis:7
  variables:
    POSTGRES_PASSWORD: test
    DATABASE_URL: postgres://postgres:test@postgres:5432/testdb
    REDIS_URL: redis://redis:6379
  script:
    - npm ci
    - npm run db:migrate
    - npm run test:coverage
  coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

build:
  stage: build
  image: docker:latest
  services:
    - docker:dind
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $DOCKER_IMAGE .
    - docker push $DOCKER_IMAGE
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy_production:
  stage: deploy
  image: alpine/k8s:1.28.0
  environment:
    name: production
    url: https://api.example.com
  script:
    - kubectl set image deployment/api api=$DOCKER_IMAGE
    - kubectl rollout status deployment/api
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual

Blue/Green Deployments

Blue/Green Deployment: Run two identical production environments. Deploy to inactive (green), test, then switch traffic from active (blue) to green.
# AWS CodeDeploy appspec.yml for Blue/Green
version: 0.0
Resources:
  - TargetService:
      Type: AWS::ECS::Service
      Properties:
        TaskDefinition: "arn:aws:ecs:region:account:task-definition/task-api"
        LoadBalancerInfo:
          ContainerName: "api"
          ContainerPort: 3000
        PlatformVersion: "LATEST"

Hooks:
  - BeforeAllowTraffic: "BeforeAllowTrafficHook"
  - AfterAllowTraffic: "AfterAllowTrafficHook"

Canary Releases

# Kubernetes canary with Istio
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: task-api
spec:
  hosts:
    - task-api
  http:
    - match:
        - headers:
            x-canary:
              exact: "true"
      route:
        - destination:
            host: task-api
            subset: canary
    - route:
        - destination:
            host: task-api
            subset: stable
          weight: 95
        - destination:
            host: task-api
            subset: canary
          weight: 5
---
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: task-api
spec:
  host: task-api
  subsets:
    - name: stable
      labels:
        version: v1
    - name: canary
      labels:
        version: v2

Terraform for APIs

# terraform/main.tf - API Infrastructure
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
  backend "s3" {
    bucket = "terraform-state-bucket"
    key    = "api/terraform.tfstate"
    region = "us-east-1"
  }
}

# API Gateway
resource "aws_apigatewayv2_api" "api" {
  name          = "task-api"
  protocol_type = "HTTP"
  
  cors_configuration {
    allow_origins = var.allowed_origins
    allow_methods = ["GET", "POST", "PUT", "DELETE"]
    allow_headers = ["Content-Type", "Authorization"]
    max_age       = 300
  }
}

resource "aws_apigatewayv2_stage" "prod" {
  api_id      = aws_apigatewayv2_api.api.id
  name        = "prod"
  auto_deploy = true
  
  access_log_settings {
    destination_arn = aws_cloudwatch_log_group.api_logs.arn
    format = jsonencode({
      requestId        = "$context.requestId"
      ip               = "$context.identity.sourceIp"
      requestTime      = "$context.requestTime"
      httpMethod       = "$context.httpMethod"
      routeKey         = "$context.routeKey"
      status           = "$context.status"
      responseLatency  = "$context.responseLatency"
    })
  }
}

# Lambda function
resource "aws_lambda_function" "api" {
  filename         = data.archive_file.lambda.output_path
  function_name    = "task-api"
  role             = aws_iam_role.lambda.arn
  handler          = "index.handler"
  runtime          = "nodejs20.x"
  source_code_hash = data.archive_file.lambda.output_base64sha256
  
  environment {
    variables = {
      DATABASE_URL = var.database_url
      NODE_ENV     = "production"
    }
  }
  
  vpc_config {
    subnet_ids         = var.private_subnet_ids
    security_group_ids = [aws_security_group.lambda.id]
  }
}

# Integration
resource "aws_apigatewayv2_integration" "lambda" {
  api_id             = aws_apigatewayv2_api.api.id
  integration_type   = "AWS_PROXY"
  integration_uri    = aws_lambda_function.api.invoke_arn
  payload_format_version = "2.0"
}

# Routes
resource "aws_apigatewayv2_route" "tasks" {
  api_id    = aws_apigatewayv2_api.api.id
  route_key = "ANY /tasks/{proxy+}"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}

output "api_endpoint" {
  value = aws_apigatewayv2_stage.prod.invoke_url
}

Practice Exercises

Exercise 1: Spectral Ruleset

Beginner 45 minutes
  • Create custom Spectral ruleset
  • Add naming convention rules
  • Integrate with pre-commit hooks

Exercise 2: GitHub Actions Pipeline

Intermediate 2 hours
  • Create multi-stage CI/CD pipeline
  • Add linting, testing, and deployment
  • Configure environment protection

Exercise 3: Terraform API Infrastructure

Advanced 3 hours
  • Define API Gateway + Lambda with Terraform
  • Add remote state and workspaces
  • Implement blue/green with Terraform
Next Steps: In Part 17: API Product Management, we'll learn API as Product thinking, monetization strategies, and building developer ecosystems.
Technology