Continuous Integration en Continuous Deployment (CI/CD) zijn geen luxe meer — ze zijn een absolute noodzaak voor elke SaaS die serieus wil groeien. Toch zien we bij SaaS Masters regelmatig teams die nog handmatig deployen, geen geautomatiseerde tests draaien, of met een fragiel deployment-script werken dat alleen de oprichter begrijpt.
In dit artikel bouwen we stap voor stap een productie-waardige CI/CD-pipeline op. Van geautomatiseerde tests tot zero-downtime deployments, van staging-omgevingen tot rollback-strategieën.
Waarom CI/CD essentieel is voor SaaS
Bij een traditioneel softwareproduct kun je misschien wegkomen met maandelijkse releases. Bij SaaS is dat anders:
- Klanten verwachten snelle bugfixes — een kritieke bug moet binnen uren opgelost zijn, niet volgende sprint
- Feature velocity bepaalt je concurrentiepositie — wie sneller levert, wint
- Downtime kost direct geld — elke minuut dat je platform offline is, verliezen klanten vertrouwen
- Meerdere omgevingen zijn noodzakelijk — development, staging, productie, en soms per-tenant omgevingen
Een goed opgezette CI/CD-pipeline maakt het verschil tussen een team dat met vertrouwen meerdere keren per dag deployt en een team dat bibbert bij elke release.
De bouwstenen van een SaaS CI/CD-pipeline
1. Versiebeheer als fundament
Alles begint bij een schone Git-workflow. Voor de meeste SaaS-teams werkt trunk-based development het beste:
main (productie)
├── feature/user-dashboard
├── feature/billing-webhook
└── fix/login-race-condition
Waarom trunk-based? Lange-levende feature branches leiden tot merge-hel. Met trunk-based development merge je kleine, afgebakende wijzigingen snel naar main. Combineer dit met feature flags (zie ons eerdere artikel over feature flags) en je kunt onafgemaakte features veilig naar productie brengen.
Branch protection rules zijn essentieel:
# GitHub branch protection
main:
required_reviews: 1
required_status_checks:
- lint
- test-unit
- test-integration
- build
dismiss_stale_reviews: true
require_up_to_date: true
2. Geautomatiseerde tests: je vangnet
Zonder tests is CI/CD zinloos — je deployt alleen sneller je bugs. Een pragmatische teststrategie voor SaaS:
Unit tests voor business logic:
// subscription.service.test.ts
describe('SubscriptionService', () => {
it('should prorate when upgrading mid-cycle', () => {
const subscription = createSubscription({
plan: 'starter',
startDate: new Date('2026-03-01'),
monthlyPrice: 49,
});
const proration = calculateProration(subscription, {
newPlan: 'professional',
newPrice: 149,
upgradeDate: new Date('2026-03-15'),
});
// 16 dagen resterend van 31 dagen
expect(proration.credit).toBeCloseTo(25.29, 2);
expect(proration.charge).toBeCloseTo(76.90, 2);
});
});
Integratietests voor API-endpoints:
// api/teams.integration.test.ts
describe('POST /api/teams', () => {
it('should enforce tenant isolation', async () => {
const teamA = await createTeam('Team A');
const teamB = await createTeam('Team B');
const response = await request(app)
.get(`/api/teams/${teamA.id}/members`)
.set('Authorization', `Bearer ${teamB.token}`);
expect(response.status).toBe(403);
});
});
E2E-tests voor kritieke flows (houd dit beperkt — ze zijn traag):
// e2e/checkout.spec.ts
test('complete checkout flow', async ({ page }) => {
await page.goto('/pricing');
await page.click('[data-plan="professional"]');
await page.fill('[data-testid="card-number"]', '4242424242424242');
await page.fill('[data-testid="card-expiry"]', '12/28');
await page.fill('[data-testid="card-cvc"]', '123');
await page.click('button[type="submit"]');
await expect(page.locator('.success-message'))
.toContainText('Welkom bij Professional!');
});
3. De pipeline configureren
Hier is een complete GitHub Actions pipeline die we regelmatig als basis gebruiken:
# .github/workflows/ci-cd.yml
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
NODE_VERSION: '20'
REGISTRY: ghcr.io
jobs:
lint-and-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
test-unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm test:unit --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
test-integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: test
ports: ['5432:5432']
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports: ['6379:6379']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm prisma migrate deploy
env:
DATABASE_URL: postgresql://postgres:test@localhost:5432/test
- run: pnpm test:integration
env:
DATABASE_URL: postgresql://postgres:test@localhost:5432/test
REDIS_URL: redis://localhost:6379
build-and-push:
needs: [lint-and-typecheck, test-unit, test-integration]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}
tags: |
type=sha,prefix=
type=raw,value=latest
- uses: docker/build-push-action@v5
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-staging:
needs: [build-and-push]
runs-on: ubuntu-latest
environment: staging
steps:
- name: Deploy to staging
run: |
kubectl set image deployment/app \
app=${{ needs.build-and-push.outputs.image-tag }} \
--namespace staging
kubectl rollout status deployment/app \
--namespace staging --timeout=300s
deploy-production:
needs: [deploy-staging]
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy to production
run: |
kubectl set image deployment/app \
app=${{ needs.build-and-push.outputs.image-tag }} \
--namespace production
kubectl rollout status deployment/app \
--namespace production --timeout=300s
- name: Notify team
run: |
curl -X POST ${{ secrets.SLACK_WEBHOOK }} \
-H 'Content-Type: application/json' \
-d '{"text": "✅ Deployed to production: ${{ github.sha }}"}'
4. Database-migraties in je pipeline
Database-migraties zijn het lastigste onderdeel van SaaS-deployments. De gouden regel: migraties moeten altijd backwards-compatible zijn.
// ❌ FOUT: dit breekt de oude code die nog draait
ALTER TABLE users RENAME COLUMN name TO full_name;
// ✅ GOED: expand-and-contract patroon
// Stap 1 (deploy 1): Voeg nieuwe kolom toe
ALTER TABLE users ADD COLUMN full_name TEXT;
UPDATE users SET full_name = name WHERE full_name IS NULL;
// Stap 2 (deploy 2): Applicatie gebruikt beide kolommen
// Stap 3 (deploy 3): Verwijder oude kolom
ALTER TABLE users DROP COLUMN name;
Gebruik een migration lock om te voorkomen dat meerdere instanties tegelijk migraties draaien:
// migrate-with-lock.ts
import { acquireAdvisoryLock, releaseAdvisoryLock } from './db';
async function runMigrations() {
const lockId = 123456; // unieke lock ID voor migraties
const acquired = await acquireAdvisoryLock(lockId);
if (!acquired) {
console.log('Another instance is running migrations, skipping...');
return;
}
try {
await prisma.$executeRaw`SELECT 1`; // health check
execSync('npx prisma migrate deploy', { stdio: 'inherit' });
} finally {
await releaseAdvisoryLock(lockId);
}
}
Zero-downtime deployments
Voor een SaaS is downtime onacceptabel. Er zijn twee bewezen strategieën:
Rolling deployments
Kubernetes doet dit standaard — oude pods worden één voor één vervangen door nieuwe:
# deployment.yaml
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # max 1 extra pod tijdens update
maxUnavailable: 0 # altijd alle pods beschikbaar
template:
spec:
containers:
- name: app
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 10
periodSeconds: 5
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
Blue-green deployments
Voor grotere wijzigingen kun je een volledig parallelle omgeving opzetten:
┌─────────────┐
│ Load Balancer│
└──────┬──────┘
│
┌────────────┼────────────┐
│ │ │
┌─────▼─────┐ ┌─────▼─────┐
│ Blue (v1) │ │ Green (v2) │
│ (actief) │ │ (staging) │
└───────────┘ └───────────┘
Na validatie switch je het verkeer van blue naar green. Probleem? Switch terug in seconden.
Rollback-strategie
Dingen gaan fout. Plan daarvoor:
#!/bin/bash
# rollback.sh - snel terug naar vorige versie
PREVIOUS_TAG=$(kubectl rollout history deployment/app -n production \
| grep -v REVISION | tail -2 | head -1 | awk '{print $1}')
echo "Rolling back to revision $PREVIOUS_TAG..."
kubectl rollout undo deployment/app -n production
# Wacht tot rollback compleet is
kubectl rollout status deployment/app -n production --timeout=300s
# Notify
curl -X POST "$SLACK_WEBHOOK" \
-H 'Content-Type: application/json' \
-d '{"text": "⚠️ ROLLBACK uitgevoerd op productie naar revision '$PREVIOUS_TAG'"}'
Automatische rollback op basis van error rates:
# Kubernetes met Argo Rollouts
apiVersion: argoproj.io/v1alpha1
kind: Rollout
spec:
strategy:
canary:
steps:
- setWeight: 10
- pause: { duration: 5m }
- setWeight: 50
- pause: { duration: 10m }
- setWeight: 100
analysis:
templates:
- templateName: error-rate
startingStep: 1
---
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: error-rate
spec:
metrics:
- name: error-rate
interval: 60s
failureLimit: 3
provider:
prometheus:
address: http://prometheus:9090
query: |
sum(rate(http_requests_total{status=~"5.*"}[5m]))
/
sum(rate(http_requests_total[5m]))
successCondition: result[0] < 0.05
Environment management
Een typische SaaS heeft minstens drie omgevingen nodig:
| Omgeving | Doel | Data | Deploy trigger |
|---|---|---|---|
| Development | Lokaal testen | Seed data | Handmatig |
| Staging | Pre-productie validatie | Geanonimiseerde kopie | Automatisch na tests |
| Production | Live klanten | Echte data | Na staging approval |
Pro tip: Gebruik preview environments voor pull requests. Tools als Vercel, Railway of Coolify maken dit eenvoudig — elke PR krijgt zijn eigen URL waar reviewers de wijzigingen live kunnen testen.
Secrets management
Hardcode nooit secrets. Gebruik een dedicated secrets manager:
// config.ts
import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
const client = new SecretManagerServiceClient();
export async function getSecret(name: string): Promise<string> {
const [version] = await client.accessSecretVersion({
name: `projects/my-saas/secrets/${name}/versions/latest`,
});
return version.payload?.data?.toString() || '';
}
// Gebruik
const stripeKey = await getSecret('STRIPE_SECRET_KEY');
const dbUrl = await getSecret('DATABASE_URL');
Monitoring na deployment
Je pipeline stopt niet bij deployment. Monitor actief na elke release:
// post-deploy-check.ts
async function postDeployHealthCheck() {
const checks = [
{ name: 'API Health', url: '/api/health' },
{ name: 'Auth Flow', url: '/api/auth/session' },
{ name: 'Database', url: '/api/health/db' },
{ name: 'Redis', url: '/api/health/redis' },
{ name: 'Stripe Webhook', url: '/api/health/stripe' },
];
for (const check of checks) {
const start = Date.now();
const response = await fetch(`https://app.example.com${check.url}`);
const duration = Date.now() - start;
if (!response.ok || duration > 5000) {
await triggerAlert({
level: 'critical',
message: `Post-deploy check failed: ${check.name}`,
details: { status: response.status, duration },
});
}
}
}
Checklist: is jouw pipeline productie-klaar?
Gebruik deze checklist om je CI/CD-pipeline te evalueren:
- Geautomatiseerde tests draaien bij elke push
- Linting en type-checking zijn verplicht
- Branch protection voorkomt directe pushes naar main
- Database-migraties zijn backwards-compatible
- Secrets staan nergens in code of Git-historie
- Zero-downtime deployments zijn geconfigureerd
- Rollback kan binnen 5 minuten uitgevoerd worden
- Staging-omgeving spiegelt productie
- Post-deploy monitoring detecteert problemen automatisch
- Deployment notificaties houden het team op de hoogte
Conclusie
Een solide CI/CD-pipeline is geen eenmalige investering — het is een levend systeem dat meegroeit met je SaaS. Begin simpel (geautomatiseerde tests + automatische deploy naar staging), en bouw geleidelijk uit naar canary deployments, automatische rollbacks en preview environments.
De initiële investering van een paar dagen werk betaalt zich terug in snellere releases, minder bugs in productie, en — misschien het belangrijkst — een team dat met vertrouwen naar productie deployt. Elke dag, meerdere keren per dag.
Wil je hulp bij het opzetten van een CI/CD-pipeline voor jouw SaaS? Neem contact op — we helpen je graag van handmatige deploys naar een volledig geautomatiseerde workflow.