Explanation

CDK8S Approach for GitLab BDA

This document explains how GitLab BDA uses CDK8S with deployment-specific constructs and patterns. For general CDK8S concepts, architecture, and benefits, see CDK8S Infrastructure as Code.

Overview

GitLab BDA deployment consists of 14 reusable TypeScript constructs that generate 80+ Kubernetes resources, including:

  • GitLab platform (via Helm chart integration)

  • Harbor container registry

  • PostgreSQL database (CloudNativePG)

  • Redis cache

  • S3 buckets for artifacts, uploads, registry, backups

  • ExternalSecrets for credential management

  • RBAC and networking

Project Structure

dp-infra/gitlabbda/
├── charts/
│   ├── gitlab-chart.ts           # Main chart (orchestrator)
│   └── constructs/               # 14 reusable constructs
│       ├── namespace.ts          # Namespace + labels
│       ├── rbac.ts               # ServiceAccounts, Roles
│       ├── s3-provider.ts        # Crossplane ProviderConfig
│       ├── s3-buckets.ts         # 8 S3 buckets
│       ├── database.ts           # PostgreSQL (CNPG)
│       ├── redis.ts              # Redis cache
│       ├── gitlab-helm.ts        # GitLab Helm chart integration
│       ├── harbor.ts             # Harbor registry
│       ├── app-secrets.ts        # ExternalSecrets
│       └── ...
├── main.ts                       # Entry point (loads config)
├── config.yaml                   # Configuration (not code!)
├── .env                          # Secrets (git-ignored)
├── manifests/
│   └── gitlab.k8s.yaml           # Generated YAML (committed to git)
├── package.json                  # Node.js dependencies
└── tsconfig.json                 # TypeScript configuration

GitLab BDA Constructs

1. Namespace and Infrastructure

NamespaceConstruct (namespace.ts)

  • Creates gitlabbda namespace

  • Adds standard labels (managed-by, part-of, version)

RbacConstruct (rbac.ts)

  • ServiceAccounts for GitLab, Harbor

  • Roles and RoleBindings

2. Storage and S3

S3ProviderConfigConstruct (s3-provider.ts)

  • Crossplane ProviderConfig for Hetzner S3

  • Credentials from ExternalSecret

S3BucketsConstruct (s3-buckets.ts)

  • Creates 8 S3 buckets:

    • gitlab-artifacts-gitlabbda-kup6s - CI/CD artifacts

    • gitlab-uploads-gitlabbda-kup6s - User uploads

    • gitlab-lfs-gitlabbda-kup6s - Git LFS objects

    • gitlab-pages-gitlabbda-kup6s - GitLab Pages

    • gitlab-registry-gitlabbda-kup6s - Container registry

    • gitlab-backups-gitlabbda-kup6s - GitLab backups

    • gitlab-postgresbackups-gitlabbda-kup6s - PostgreSQL backups

    • gitlab-cache-gitlabbda-kup6s - Build cache

3. Databases and Caches

DatabaseConstruct (database.ts)

  • CloudNativePG Cluster (2 replicas)

  • Storage class: longhorn-redundant-app (1 replica, PostgreSQL has own replication)

  • Barman backup to S3

  • PgBouncer pooler

  • Bootstrap from existing cluster

RedisConstruct (redis.ts)

  • Redis StatefulSet

  • Persistent storage

  • Service for GitLab

4. Applications

GitlabHelmConstruct (gitlab-helm.ts)

  • Key pattern: Integrates existing Helm chart with CDK8S

  • Generates Helm values from TypeScript config

  • Renders Helm chart to YAML

  • Includes rendered resources in CDK8S output

Example:

export class GitlabHelmConstruct extends Construct {
  constructor(scope: Construct, id: string, config: AppConfig) {
    super(scope, id);

    // Generate Helm values from config (type-safe!)
    const helmValues = {
      global: {
        hosts: {
          domain: config.domains.gitlab,
        },
        psql: {
          host: 'gitlab-postgres-pooler',
          password: {
            secret: 'gitlab-postgres-app',
            key: 'password',
          },
        },
        minio: {
          enabled: false,  // Use Hetzner S3 instead
        },
        registry: {
          bucket: 'gitlab-registry-gitlabbda-kup6s',
        },
      },
      gitlab: {
        webservice: {
          replicas: config.replicas.webservice,
        },
      },
    };

    // Render Helm chart (no Helm runtime needed in cluster!)
    const rendered = helmRender('gitlab/gitlab', helmValues);

    // Include in CDK8S output as standard K8S resources
    new kplus.ApiObject(this, 'gitlab-resources', {
      apiVersion: 'v1',
      kind: 'List',
      spec: rendered,
    });
  }
}

Benefits of this pattern:

  • Reuse complex Helm charts (GitLab has 100+ resources)

  • Type-safe values generation (config → Helm values)

  • No Helm runtime in cluster (ArgoCD sees plain YAML)

  • Full control over Helm values via TypeScript

See gitlab-helm.ts for implementation.

HarborConstruct (harbor.ts)

  • Harbor container registry

  • Similar Helm integration pattern

5. Secret Management

AppSecretsConstruct (app-secrets.ts)

  • Key pattern: ExternalSecret resources via CDK8S

  • Syncs secrets from ClusterSecretStore

  • Adds sync wave annotations programmatically

Example:

export class AppSecretsConstruct extends Construct {
  constructor(scope: Construct, id: string, config: AppConfig) {
    super(scope, id);

    // ExternalSecret for GitLab secrets
    new ExternalSecret(this, 'gitlab-secrets', {
      metadata: {
        namespace: config.namespace,
        annotations: {
          'argocd.argoproj.io/sync-wave': '2',  // After ClusterSecretStore (wave 1)
        },
      },
      spec: {
        refreshInterval: '1h',
        secretStoreRef: {
          name: 'gitlabbda-app-secrets-store',
          kind: 'ClusterSecretStore',
        },
        target: {
          name: 'gitlab-secrets',
          creationPolicy: 'Owner',
        },
        dataFrom: [{
          extract: {
            key: 'gitlabbda-app-secrets-source',
          },
        }],
      },
    });
  }
}

Benefits:

  • ESO resources generated from type-safe code

  • Sync waves enforced programmatically

  • Consistent secret patterns across constructs

Configuration Pattern

GitLab BDA separates configuration from code:

# config.yaml (data, committed to git)
versions:
  gitlab: v18.5.1
  harbor: v2.14.0
domains:
  gitlab: gitlab.staging.bluedynamics.eu
  harbor: harbor.staging.bluedynamics.eu
replicas:
  webservice: 2
  sidekiq: 1
storage:
  postgresql: 10Gi
  redis: 5Gi
// main.ts (loads and validates config)
import * as yaml from 'yaml';
import * as fs from 'fs';

const configFile = fs.readFileSync('config.yaml', 'utf-8');
const config: AppConfig = yaml.parse(configFile);

// Override with environment variables
if (process.env.GITLAB_VERSION) {
  config.versions.gitlab = process.env.GITLAB_VERSION;
}

// Create app and chart
const app = new App();
new GitlabChart(app, 'gitlab', config);
app.synth();

Benefits:

  • Config is pure data (no code)

  • Type-safe via AppConfig interface

  • Environment-specific configs (staging vs production)

  • IDE autocomplete for config fields

Build Workflow

# CRITICAL: Source .env to load secrets
bash -c 'source .env && npm run build'

What happens:

  1. main.ts reads config.yaml and process.env (.env vars)

  2. TypeScript compiles to JavaScript (npm run compile)

  3. CDK8S constructs instantiated with config

  4. CDK8S generates manifests/gitlab.k8s.yaml (npm run synth)

  5. Commit manifests to git

  6. ArgoCD deploys to cluster

Construct Composition

The main chart composes all 14 constructs:

// charts/gitlab-chart.ts
export class GitlabChart extends Chart {
  constructor(scope: Construct, id: string, config: AppConfig) {
    super(scope, id);

    // Infrastructure (wave 1)
    new NamespaceConstruct(this, 'namespace', config);
    new RbacConstruct(this, 'rbac', config);
    new S3ProviderConfigConstruct(this, 's3-provider', config);
    new S3BucketsConstruct(this, 's3-buckets', config);

    // Secrets (wave 2)
    new AppSecretsConstruct(this, 'secrets', config);

    // Databases and apps (wave 3)
    new DatabaseConstruct(this, 'database', config);
    new RedisConstruct(this, 'redis', config);
    new GitlabHelmConstruct(this, 'gitlab', config);
    new HarborConstruct(this, 'harbor', config);
  }
}

Benefits:

  • Clear dependency order (waves)

  • Each construct independently testable

  • Easy to add/remove components

  • High-level view of deployment

Unique GitLab BDA Patterns

1. Helm Chart Integration

Challenge: GitLab Helm chart has 100+ resources, complex configuration

Solution: Render Helm chart via CDK8S, output as plain YAML

  • Best of both worlds: Helm expertise + CDK8S type safety

  • ArgoCD sees plain YAML (no Helm runtime needed)

  • Full control over Helm values via TypeScript

2. Multi-Bucket S3 Strategy

Challenge: GitLab needs 8 different S3 buckets for different purposes

Solution: Single construct creates all buckets with consistent naming

  • All follow pattern: gitlab-{purpose}-gitlabbda-kup6s

  • All use same Crossplane ProviderConfig

  • All configured with managementPolicies to skip tagging (Hetzner limitation)

3. PostgreSQL with Barman Backup

Challenge: PostgreSQL backups to S3 with CNPG

Solution: DatabaseConstruct configures Barman cloud backups

backup: {
  barmanObjectStore: {
    destinationPath: `s3://gitlab-postgresbackups-gitlabbda-kup6s/`,
    s3Credentials: {
      accessKeyId: { /* from secret */ },
      secretAccessKey: { /* from secret */ },
    },
    wal: {
      compression: 'gzip',
      encryption: 'AES256',
    },
  },
  retentionPolicy: '30d',
}

4. Environment-Specific Builds

Staging:

cp config.yaml config-active.yaml
bash -c 'source .env && npm run build'

Production:

cp config-production.yaml config-active.yaml
bash -c 'source .env && npm run build'

Different domains, replica counts, storage sizes via configuration.

Testing

GitLab BDA constructs are unit-tested:

// tests/constructs/database.test.ts
describe('DatabaseConstruct', () => {
  it('should create CNPG cluster with correct storage class', () => {
    const chart = Testing.chart();
    const config = createTestConfig();

    new DatabaseConstruct(chart, 'test', { config });

    const manifests = Testing.synth(chart);
    const cluster = findResource(manifests, 'Cluster');

    expect(cluster.spec.storage.storageClass).toBe('longhorn-redundant-app');
  });

  it('should configure Barman S3 backups', () => {
    const chart = Testing.chart();
    const config = createTestConfig();

    new DatabaseConstruct(chart, 'test', { config });

    const manifests = Testing.synth(chart);
    const cluster = findResource(manifests, 'Cluster');

    expect(cluster.spec.backup.barmanObjectStore.destinationPath)
      .toContain('s3://gitlab-postgresbackups-gitlabbda-kup6s');
  });
});

Troubleshooting

Missing .env File

Symptoms: S3 credentials show undefined in manifests

Solution:

# Verify .env exists
cat .env | grep HETZNER_S3_ACCESS_KEY

# Ensure using bash -c 'source .env'
bash -c 'source .env && npm run build'

Helm Values Not Applied

Symptoms: GitLab deployed with default values, ignoring config

Solution: Check GitlabHelmConstruct generates correct Helm values

# Inspect generated manifest
grep -A 20 'global:' manifests/gitlab.k8s.yaml

CNPG Cluster Not Starting

Symptoms: PostgreSQL pods in Pending

Common causes:

  • PVC not created (check Longhorn)

  • Barman credentials invalid (check ExternalSecret)

  • Sync wave out of order (database before secrets)

See CLAUDE.md for comprehensive troubleshooting.