Skip to main content

Building CI/CD Pipelines for Fintech Teams

00:07:52:50

Why Fintech CI/CD Is Different

Every engineering team wants fast, reliable deployments. But fintech adds layers that most CI/CD tutorials ignore: regulatory compliance gates, database migration safety for financial data, rollback strategies that don't lose transactions, and audit trails that satisfy regulators during inspections.

I've built CI/CD pipelines for trading platforms, payment processors, and brokerage CRMs. The common thread is that you can't treat deployment as a purely technical problem. Your pipeline needs to encode your compliance requirements, your rollback strategy, and your team's deployment confidence level into executable code.

Let me walk through the pipeline architecture I use, built on GitLab CI/CD but applicable to any CI system.

Pipeline Architecture Overview

A fintech pipeline has more stages than a typical web app. Here's the structure I use:

yaml
# .gitlab-ci.yml
stages:
  - lint
  - test
  - security
  - build
  - staging
  - compliance-gate
  - production
  - post-deploy

variables:
  PHP_VERSION: "8.3"
  NODE_VERSION: "20"
  POSTGRES_VERSION: "16"
  REDIS_VERSION: "7"
  DOCKER_REGISTRY: registry.gitlab.com/company/platform

The compliance-gate stage is what sets fintech apart. It's a manual approval step that ensures a human (usually a senior engineer or compliance officer) signs off before production deployment. In regulated environments, fully automated production deploys are often not permitted.

Lint and Static Analysis

The first stage catches obvious problems before wasting time on expensive test runs.

yaml
lint:php:
  stage: lint
  image: php:${PHP_VERSION}-cli
  before_script:
    - composer install --no-interaction --prefer-dist
  script:
    - vendor/bin/phpstan analyse --level=8 --memory-limit=512M
    - vendor/bin/php-cs-fixer fix --dry-run --diff
  rules:
    - if: $CI_MERGE_REQUEST_IID
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

lint:migrations:
  stage: lint
  image: php:${PHP_VERSION}-cli
  script:
    - |
      # Check that no migration modifies a column that holds financial data
      # without a corresponding audit log migration
      FINANCIAL_TABLES="transactions balances commissions withdrawals deposits"
      for table in $FINANCIAL_TABLES; do
        MODIFIED=$(grep -rl "Schema::table('${table}'" database/migrations/ \
          --include="*.php" | grep -v "audit" || true)
        if [ -n "$MODIFIED" ]; then
          echo "WARNING: Financial table '${table}' modified in:"
          echo "$MODIFIED"
          echo "Ensure corresponding audit trail migration exists."
        fi
      done
  allow_failure: true

The migration linter is fintech-specific. When someone modifies a table that holds financial data, the pipeline flags it so reviewers know to check for audit trail coverage.

Automated Testing Strategy

I split tests into three tiers. Unit tests run in seconds, integration tests need a database, and end-to-end tests simulate real user flows including PSP callbacks.

yaml
test:unit:
  stage: test
  image: php:${PHP_VERSION}-cli
  before_script:
    - composer install --no-interaction
  script:
    - vendor/bin/phpunit --testsuite=Unit --coverage-text --coverage-cobertura=coverage.xml
  coverage: '/Lines:\s*(\d+\.\d+)%/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml

test:integration:
  stage: test
  image: php:${PHP_VERSION}-cli
  services:
    - name: postgres:${POSTGRES_VERSION}
      alias: db
      variables:
        POSTGRES_DB: testing
        POSTGRES_USER: test
        POSTGRES_PASSWORD: test
    - name: redis:${REDIS_VERSION}
      alias: redis
  variables:
    DB_HOST: db
    DB_DATABASE: testing
    DB_USERNAME: test
    DB_PASSWORD: test
    REDIS_HOST: redis
  before_script:
    - composer install --no-interaction
    - php artisan migrate --force
    - php artisan db:seed --class=TestingSeeder --force
  script:
    - vendor/bin/phpunit --testsuite=Integration
  artifacts:
    when: on_failure
    paths:
      - storage/logs/laravel.log

test:e2e:
  stage: test
  image: cypress/included:13.0.0
  services:
    - name: ${DOCKER_REGISTRY}/app:${CI_COMMIT_SHA}
      alias: app
    - name: postgres:${POSTGRES_VERSION}
      alias: db
    - name: redis:${REDIS_VERSION}
      alias: redis
  script:
    - cypress run --config baseUrl=http://app:8080
  artifacts:
    when: on_failure
    paths:
      - cypress/screenshots/
      - cypress/videos/

For fintech, I enforce minimum coverage thresholds on critical modules:

php
// phpunit.xml — enforce coverage on financial modules
<coverage>
    <report>
        <text outputFile="php://stdout" showOnlySummary="true"/>
    </report>
    <source>
        <include>
            <directory suffix=".php">app/Services/Payment</directory>
            <directory suffix=".php">app/Services/Commission</directory>
            <directory suffix=".php">app/Services/Withdrawal</directory>
        </include>
    </source>
</coverage>

Security Scanning

This stage runs in parallel with testing. It catches dependency vulnerabilities, container image issues, and secrets that might have leaked into the codebase.

yaml
security:dependencies:
  stage: security
  image: php:${PHP_VERSION}-cli
  script:
    - composer audit --format=json > audit-results.json
    - |
      CRITICAL=$(cat audit-results.json | python3 -c "
      import json, sys
      data = json.load(sys.stdin)
      advisories = data.get('advisories', {})
      critical = sum(1 for pkg in advisories.values()
                     for a in pkg if a.get('severity') == 'critical')
      print(critical)
      ")
      if [ "$CRITICAL" -gt 0 ]; then
        echo "CRITICAL vulnerabilities found. Blocking deployment."
        exit 1
      fi
  artifacts:
    paths:
      - audit-results.json

security:secrets:
  stage: security
  image: trufflesecurity/trufflehog:latest
  script:
    - trufflehog git file://. --since-commit HEAD~10 --fail
  allow_failure: false

security:container:
  stage: security
  image:
    name: aquasec/trivy:latest
    entrypoint: [""]
  script:
    - trivy image --exit-code 1 --severity CRITICAL,HIGH
        ${DOCKER_REGISTRY}/app:${CI_COMMIT_SHA}
  needs:
    - build:docker

The secrets scanner (trufflehog) is non-negotiable in fintech. A leaked API key to a PSP can result in fraudulent transactions. I've seen it happen.

Docker Build

yaml
build:docker:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build
        --build-arg PHP_VERSION=${PHP_VERSION}
        --cache-from ${DOCKER_REGISTRY}/app:latest
        -t ${DOCKER_REGISTRY}/app:${CI_COMMIT_SHA}
        -t ${DOCKER_REGISTRY}/app:latest
        .
    - docker push ${DOCKER_REGISTRY}/app:${CI_COMMIT_SHA}
    - docker push ${DOCKER_REGISTRY}/app:latest
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Database Migration Safety

This is where most teams get burned. Running ALTER TABLE on a table with millions of financial records can lock the table for minutes. I use a pre-deployment migration check that flags dangerous operations.

python
#!/usr/bin/env python3
"""migration_safety_check.py — Analyze SQL migrations for dangerous operations."""

import re
import sys
import glob

DANGEROUS_PATTERNS = [
    (r'ALTER TABLE.*ADD COLUMN.*NOT NULL(?!.*DEFAULT)',
     'Adding NOT NULL column without DEFAULT locks the table on large datasets'),
    (r'ALTER TABLE.*DROP COLUMN',
     'Dropping a column is irreversible — ensure data has been backed up'),
    (r'ALTER TABLE.*MODIFY|ALTER TABLE.*CHANGE',
     'Column type changes can lock tables and cause data loss'),
    (r'DROP TABLE',
     'Table drop detected — ensure this is intentional and data is archived'),
    (r'TRUNCATE',
     'TRUNCATE on financial data is almost certainly wrong'),
    (r'DELETE FROM(?!.*WHERE)',
     'DELETE without WHERE clause — this will delete all rows'),
]

def check_migration(filepath: str) -> list[dict]:
    issues = []
    with open(filepath, 'r') as f:
        content = f.read()

    for pattern, message in DANGEROUS_PATTERNS:
        matches = re.finditer(pattern, content, re.IGNORECASE)
        for match in matches:
            line_num = content[:match.start()].count('\n') + 1
            issues.append({
                'file': filepath,
                'line': line_num,
                'message': message,
                'matched': match.group()[:80],
            })

    return issues

if __name__ == '__main__':
    migration_files = glob.glob('database/migrations/**/*.php', recursive=True)
    all_issues = []

    for f in migration_files:
        all_issues.extend(check_migration(f))

    if all_issues:
        print(f"\n{'='*60}")
        print(f"  MIGRATION SAFETY CHECK: {len(all_issues)} issue(s) found")
        print(f"{'='*60}\n")
        for issue in all_issues:
            print(f"  [{issue['file']}:{issue['line']}]")
            print(f"  {issue['message']}")
            print(f"  Matched: {issue['matched']}")
            print()
        sys.exit(1)

    print("Migration safety check passed.")

Integrate it into the pipeline:

yaml
check:migrations:
  stage: test
  image: python:3.12-slim
  script:
    - python3 scripts/migration_safety_check.py
  rules:
    - if: $CI_MERGE_REQUEST_IID
      changes:
        - database/migrations/**/*

Compliance Gate and Production Deployment

The compliance gate is a manual approval stage. For regulated deployments, you need a record of who approved the deployment and when.

yaml
compliance-gate:
  stage: compliance-gate
  script:
    - |
      echo "Deployment approved by: ${GITLAB_USER_NAME}"
      echo "Approved at: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
      echo "Commit: ${CI_COMMIT_SHA}"
      echo "Pipeline: ${CI_PIPELINE_URL}"

      # Log approval to audit system
      curl -s -X POST "${AUDIT_API_URL}/deployments" \
        -H "Authorization: Bearer ${AUDIT_API_TOKEN}" \
        -H "Content-Type: application/json" \
        -d "{
          \"approved_by\": \"${GITLAB_USER_NAME}\",
          \"commit_sha\": \"${CI_COMMIT_SHA}\",
          \"environment\": \"production\",
          \"pipeline_url\": \"${CI_PIPELINE_URL}\"
        }"
  when: manual
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  allow_failure: false

deploy:production:
  stage: production
  image: bitnami/kubectl:latest
  needs:
    - compliance-gate
    - build:docker
    - security:container
  script:
    - kubectl set image deployment/app
        app=${DOCKER_REGISTRY}/app:${CI_COMMIT_SHA}
        --namespace=production
    - kubectl rollout status deployment/app
        --namespace=production
        --timeout=300s
  environment:
    name: production
    url: https://platform.company.com
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Rollback Strategy

Kubernetes gives you built-in rollback with kubectl rollout undo, but you also need to handle database rollbacks. My approach: make every migration reversible, and include a rollback job in the pipeline.

yaml
rollback:production:
  stage: post-deploy
  image: bitnami/kubectl:latest
  script:
    - echo "Rolling back to previous deployment..."
    - kubectl rollout undo deployment/app --namespace=production
    - kubectl rollout status deployment/app --namespace=production --timeout=300s

    # Log rollback to audit system
    - |
      curl -s -X POST "${AUDIT_API_URL}/rollbacks" \
        -H "Authorization: Bearer ${AUDIT_API_TOKEN}" \
        -H "Content-Type: application/json" \
        -d "{
          \"rolled_back_by\": \"${GITLAB_USER_NAME}\",
          \"commit_sha\": \"${CI_COMMIT_SHA}\",
          \"environment\": \"production\",
          \"reason\": \"Manual rollback triggered\"
        }"
  when: manual
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Post-Deploy Verification

After every production deployment, run smoke tests that verify critical financial flows are working.

yaml
post-deploy:smoke:
  stage: post-deploy
  image: curlimages/curl:latest
  needs:
    - deploy:production
  script:
    - |
      echo "Running smoke tests against production..."

      # Health check
      STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://platform.company.com/health)
      if [ "$STATUS" != "200" ]; then
        echo "Health check failed with status $STATUS"
        exit 1
      fi

      # API authentication
      TOKEN=$(curl -s -X POST https://platform.company.com/api/auth/login \
        -H "Content-Type: application/json" \
        -d "{\"email\":\"${SMOKE_TEST_EMAIL}\",\"password\":\"${SMOKE_TEST_PASSWORD}\"}" \
        | python3 -c "import json,sys; print(json.load(sys.stdin)['token'])")

      # Verify trading instruments endpoint
      INSTRUMENTS=$(curl -s -o /dev/null -w "%{http_code}" \
        -H "Authorization: Bearer $TOKEN" \
        https://platform.company.com/api/v1/instruments)
      if [ "$INSTRUMENTS" != "200" ]; then
        echo "Instruments endpoint failed"
        exit 1
      fi

      echo "All smoke tests passed."
  allow_failure: false

If smoke tests fail, the rollback job is right there in the pipeline, one click away. In over two years of using this setup, we've triggered manual rollbacks fewer than five times — but each time, the process took under 60 seconds because the pipeline had everything ready.

Key Takeaways

  1. Add a compliance gate before production. In regulated fintech, fully automated production deploys are often not permitted. A manual approval stage with audit logging satisfies both regulatory requirements and gives your team a safety checkpoint.
  2. Run migration safety checks as part of CI. Catching a table-locking ALTER TABLE in a merge request review is infinitely better than discovering it during a production deployment.
  3. Scan for secrets on every push. A leaked PSP API key can lead to real financial damage. Tools like TruffleHog take seconds to run and catch mistakes before they reach production.
  4. Make rollbacks a one-click operation. Every second of downtime on a trading platform costs money. Pre-configured rollback jobs in your pipeline mean you're never scrambling to remember kubectl commands under pressure.
  5. Run post-deploy smoke tests against real endpoints. A successful deployment means nothing if the application is returning errors. Automated smoke tests on critical financial flows give you immediate confidence or immediate warning.

Want to discuss this topic or work together? Get in touch.

Contact me

Related Articles

agile-leadership-in-fintech-teams

api-design-for-fintech-platforms

building-scalable-fintech-crms-with-laravel