Terug naar blog
CI/CDDevOpsdeploymenttestingGitHub ActionsKubernetes

CI/CD voor je SaaS: van handmatige deploys naar een volautomatische pipeline

Door SaaS Masters14 maart 20269 min leestijd
CI/CD voor je SaaS: van handmatige deploys naar een volautomatische pipeline

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:

OmgevingDoelDataDeploy trigger
DevelopmentLokaal testenSeed dataHandmatig
StagingPre-productie validatieGeanonimiseerde kopieAutomatisch na tests
ProductionLive klantenEchte dataNa 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.