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:
# .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.
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.
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:
// 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.
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
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.
#!/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:
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.
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.
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.
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
- 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.
- Run migration safety checks as part of CI. Catching a table-locking
ALTER TABLEin a merge request review is infinitely better than discovering it during a production deployment. - 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.
- 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.
- 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.
