Shared Constructs Architecture

This document explains the CDK8S shared constructs pattern used for Nextcloud deployments.

Problem Statement

Without shared constructs:

  • Code duplication across instances

  • Inconsistent deployments (each instance slightly different)

  • Maintenance burden (fix bug once per instance)

  • Testing complexity (validate each instance separately)

Solution: Shared CDK8S Package

All Nextcloud instances use constructs from packages/nextcloud-shared/:

// Instance-specific chart orchestrates shared constructs
import * as nc from '@kup6s/nextcloud-shared';

new nc.PostgresConstruct(this, 'postgres', {...});
new nc.RedisConstruct(this, 'redis', {...});
new nc.NextcloudHelmConstruct(this, 'nextcloud', {...});
new nc.CollaboraConstruct(this, 'collabora', {...});

Benefits:

  • ✅ Single source of truth for deployment logic

  • ✅ Test once, deploy many times

  • ✅ Consistent architecture across instances

  • ✅ Easy to add new instances

  • ✅ Version control for infrastructure patterns

Package Structure

dp-kup/internal/nextcloud/
├── packages/nextcloud-shared/        # Shared constructs (NPM workspace package)
│   ├── package.json
│   ├── src/
│   │   ├── types.ts                  # TypeScript interfaces
│   │   ├── namespace.ts              # Wave 0: Namespace
│   │   ├── s3-buckets.ts             # Wave 1: Crossplane S3 buckets
│   │   ├── s3-credentials.ts         # Wave 1: ESO credentials from Infisical
│   │   ├── postgres.ts               # Wave 2: CloudNativePG cluster
│   │   ├── redis.ts                  # Wave 2: Redis StatefulSet
│   │   ├── nextcloud-helm.ts         # Wave 4: Nextcloud via Helm
│   │   ├── collabora.ts              # Wave 4: Collabora Office
│   │   ├── whiteboard.ts             # Wave 4: Whiteboard
│   │   └── index.ts                  # Export all constructs
│   └── dist/                         # Compiled JavaScript
├── nextcloudkup/                     # Instance 1
│   ├── config.yaml                   # Instance-specific config
│   ├── charts/nextcloud-chart.ts     # Orchestration (uses shared constructs)
│   ├── main.ts                       # CDK8S app entry point
│   └── manifests/nextcloudkup.k8s.yaml  # Generated K8s manifests
└── nextcloudaffenstall/              # Instance 2
    ├── config.yaml
    ├── charts/nextcloud-chart.ts     # Same orchestration logic
    ├── main.ts
    └── manifests/nextcloudaffenstall.k8s.yaml

Construct Descriptions

1. NamespaceConstruct

Purpose: Create Kubernetes namespace with ArgoCD sync-wave 0

Sync Wave: 0 (must exist before other resources)

Implementation:

export class NamespaceConstruct extends Construct {
  constructor(scope: Construct, id: string, props: NamespaceProps) {
    super(scope, id);

    new KubeNamespace(this, 'namespace', {
      metadata: {
        name: props.name,
        annotations: {
          'argocd.argoproj.io/sync-wave': '0',
        },
      },
    });
  }
}

Usage:

new nc.NamespaceConstruct(this, 'namespace', {
  name: 'nextcloudkup',
});

2. S3BucketsConstruct

Purpose: Create S3 buckets via Crossplane for data and backups

Sync Wave: 1 (external dependencies before data layer)

Crossplane Resources:

apiVersion: s3.aws.upbound.io/v1beta1
kind: Bucket
metadata:
  name: data-nextcloudkup-kup6s
spec:
  forProvider:
    region: fsn1
  providerConfigRef:
    name: hetzner-s3
  deletionPolicy: Orphan  # Preserve bucket on delete

Deletion Policy: Orphan prevents accidental data loss when deleting ArgoCD app.

Implementation:

export class S3BucketsConstruct extends Construct {
  constructor(scope: Construct, id: string, props: S3BucketsProps) {
    for (const [key, bucketName] of Object.entries(props.buckets)) {
      new ApiObject(this, `bucket-${key}`, {
        apiVersion: 's3.aws.upbound.io/v1beta1',
        kind: 'Bucket',
        metadata: {
          name: bucketName,
          annotations: {
            'argocd.argoproj.io/sync-wave': '1',
          },
        },
        spec: {
          forProvider: { region: 'fsn1' },
          providerConfigRef: { name: 'hetzner-s3' },
          deletionPolicy: 'Orphan',
        },
      });
    }
  }
}

3. S3CredentialsConstruct

Purpose: Inject S3 credentials from Infisical via External Secrets Operator

Sync Wave: 1 (needed before Nextcloud starts)

ESO ExternalSecret:

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: nextcloud-s3-credentials-es
spec:
  refreshInterval: 15m
  secretStoreRef:
    name: infisical-secret-store
    kind: ClusterSecretStore
  target:
    name: nextcloud-s3-credentials
    template:
      data:
        AWS_ACCESS_KEY_ID: "{{ .s3_access_key }}"
        AWS_SECRET_ACCESS_KEY: "{{ .s3_secret_key }}"
  dataFrom:
    - extract:
        key: nextcloud-kup6s-s3-credentials  # Path in Infisical

Secret Rotation: ESO refreshes every 15 minutes from Infisical.

4. PostgresConstruct

Purpose: Deploy CloudNativePG PostgreSQL cluster with automated backups

Sync Wave: 2 (data layer before app layer)

CNPG Cluster:

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: nextcloud-postgres
spec:
  instances: 2
  imageName: ghcr.io/cloudnative-pg/postgresql:16
  bootstrap:
    initdb:
      database: nextcloud
      owner: nextcloud
      secret:
        name: nextcloud-postgres-app
  storage:
    storageClass: longhorn
    size: 10Gi
  backup:
    barmanObjectStore:
      destinationPath: s3://backups-nextcloudkup-kup6s/postgres
      s3Credentials:
        inheritFromIAMRole: false
        accessKeyId:
          name: nextcloud-s3-credentials
          key: AWS_ACCESS_KEY_ID
        secretAccessKey:
          name: nextcloud-s3-credentials
          key: AWS_SECRET_ACCESS_KEY
      wal:
        compression: gzip
      data:
        compression: gzip
    retentionPolicy: "30d"
  resources:
    requests:
      cpu: 100m
      memory: 256Mi
    limits:
      cpu: 500m
      memory: 512Mi

Pooler (PgBouncer):

apiVersion: postgresql.cnpg.io/v1
kind: Pooler
metadata:
  name: nextcloud-postgres-pooler
spec:
  cluster:
    name: nextcloud-postgres
  instances: 2
  type: rw
  pgbouncer:
    poolMode: session
    parameters:
      max_client_conn: "1000"
      default_pool_size: "25"

Secret Management: CNPG auto-creates nextcloud-postgres-app secret with credentials.

5. RedisConstruct

Purpose: Deploy Redis for caching and session management

Sync Wave: 2 (data layer)

StatefulSet Configuration:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: redis
spec:
  serviceName: redis
  replicas: 1
  selector:
    matchLabels:
      app: redis
  template:
    spec:
      containers:
      - name: redis
        image: redis:7.4
        command: ["redis-server"]
        args:
          - --appendonly no        # Disable persistence (cache only)
          - --save ""              # Disable RDB snapshots
        volumeMounts:
        - name: data
          mountPath: /data
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: [ReadWriteOnce]
      storageClassName: longhorn
      resources:
        requests:
          storage: 5Gi

Why Disable Persistence?

  • Redis used only for caching (not persistent data)

  • Faster performance without fsync overhead

  • Cache rebuilds automatically on restart

6. NextcloudHelmConstruct

Purpose: Render Nextcloud Helm chart and include in CDK8S

Sync Wave: 4 (application layer)

Key Features:

  • Wraps official Nextcloud Helm chart

  • Injects configuration from CDK8S TypeScript

  • Runs helm template to generate manifests

  • Post-processes YAML (adds sync-wave annotation)

  • Includes via CDK8S Include construct

Implementation Approach:

export class NextcloudHelmConstruct extends Construct {
  constructor(scope: Construct, id: string, props: NextcloudHelmProps) {
    // 1. Build Helm values from props
    const helmValues = {
      replicaCount: props.replicas,
      ingress: { ... },
      nextcloud: {
        objectStore: {
          s3: {
            existingSecret: 'nextcloud-s3-credentials',
            secretKeys: {
              accessKey: 'AWS_ACCESS_KEY_ID',
              secretKey: 'AWS_SECRET_ACCESS_KEY',
            },
          },
        },
      },
      externalDatabase: {
        host: 'nextcloud-postgres-pooler',
        existingSecret: { secretName: 'nextcloud-postgres-app' },
      },
    };

    // 2. Write values to temp file
    fs.writeFileSync('/tmp/values.yaml', yaml.dump(helmValues));

    // 3. Run helm template
    const renderedYaml = execSync(
      `helm template nextcloud nextcloud/nextcloud --values /tmp/values.yaml`,
      { encoding: 'utf-8' }
    );

    // 4. Post-process: Add sync-wave annotation
    const docs = yaml.loadAll(renderedYaml);
    docs.forEach(doc => {
      doc.metadata.annotations = doc.metadata.annotations || {};
      doc.metadata.annotations['argocd.argoproj.io/sync-wave'] = '4';
    });

    // 5. Include in CDK8S
    new Include(this, 'manifests', {
      url: '/tmp/processed.yaml',
    });
  }
}

Benefits of Helm Wrapper:

  • Leverage official Nextcloud Helm chart (maintained by community)

  • Type-safe configuration via TypeScript

  • Automated sync-wave injection

  • Consistent with CDK8S patterns

7. CollaboraConstruct

Purpose: Deploy Collabora Online for document editing

Sync Wave: 4 (application layer)

Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: collabora
spec:
  replicas: 2
  template:
    spec:
      containers:
      - name: collabora
        image: collabora/code:25.04.8.2.1
        env:
        - name: domain
          value: affenstall\\.cloud  # Escaped dots for regex
        - name: username
          value: admin
        - name: password
          valueFrom:
            secretKeyRef:
              name: collabora-admin
              key: password
        ports:
        - containerPort: 9980

Integration: Nextcloud connects via WOPI protocol to collabora:9980.

8. WhiteboardConstruct

Purpose: Deploy Nextcloud Whiteboard app

Sync Wave: 4 (application layer)

Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: whiteboard
spec:
  replicas: 1
  template:
    spec:
      containers:
      - name: whiteboard
        image: ghcr.io/nextcloud-releases/whiteboard:release
        env:
        - name: NEXTCLOUD_URL
          value: https://affenstall.cloud
        - name: JWT_SECRET_KEY
          valueFrom:
            secretKeyRef:
              name: whiteboard-jwt
              key: secret
        ports:
        - containerPort: 3002

Integration: Nextcloud whiteboard app connects via JWT-authenticated WebSocket.

Instance Orchestration

Each instance has a nextcloud-chart.ts that orchestrates the constructs:

// nextcloudkup/charts/nextcloud-chart.ts
import * as nc from '@kup6s/nextcloud-shared';

export class NextcloudChart extends Chart {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    // Load instance-specific config
    const config = yaml.load(fs.readFileSync('config.yaml'));

    // Wave 0: Namespace
    new nc.NamespaceConstruct(this, 'namespace', {
      name: config.namespace,
    });

    // Wave 1: S3 Storage
    new nc.S3BucketsConstruct(this, 's3-buckets', {
      namespace: config.namespace,
      buckets: config.s3.buckets,
    });
    new nc.S3CredentialsConstruct(this, 's3-credentials', {
      namespace: config.namespace,
      s3: config.s3,
    });

    // Wave 2: Data Layer
    new nc.PostgresConstruct(this, 'postgres', {
      namespace: config.namespace,
      replicas: config.replicas.postgres,
      resources: config.resources.postgres,
    });
    new nc.RedisConstruct(this, 'redis', {
      namespace: config.namespace,
      replicas: config.replicas.redis,
      resources: config.resources.redis,
    });

    // Wave 4: Application Layer
    new nc.NextcloudHelmConstruct(this, 'nextcloud', {
      namespace: config.namespace,
      domain: config.domain,
      version: config.versions.nextcloud,
      s3: config.s3,
      replicas: config.replicas.nextcloud,
      resources: config.resources.nextcloud,
    });

    if (config.collabora?.enabled) {
      new nc.CollaboraConstruct(this, 'collabora', {
        namespace: config.namespace,
        domain: config.collabora.domain,
        version: config.versions.collabora,
      });
    }

    if (config.whiteboard?.enabled) {
      new nc.WhiteboardConstruct(this, 'whiteboard', {
        namespace: config.namespace,
        nextcloudUrl: `https://${config.domain}`,
        version: config.versions.whiteboard,
      });
    }
  }
}

Key Points:

  • Same logic for all instances

  • Only config.yaml differs

  • Constructs handle all K8s resource generation

Development Workflow

1. Update Shared Construct

cd dp-kup/internal/nextcloud/packages/nextcloud-shared
# Edit src/postgres.ts
npm run compile  # TypeScript → JavaScript

2. Regenerate All Instance Manifests

cd dp-kup/internal/nextcloud
npm run compile    # Compile all workspaces
npm run synth      # Generate manifests for all instances

3. Commit and Deploy

git add .
git commit -m "fix(nextcloud): update PostgreSQL resources"
git push
# ArgoCD auto-syncs both instances

Impact: Both nextcloudkup and nextcloudaffenstall get the fix automatically.

Adding a New Instance

1. Create Instance Directory

cd dp-kup/internal/nextcloud
mkdir nextcloudexample
cd nextcloudexample

2. Copy Boilerplate

# Copy from existing instance
cp ../nextcloudkup/{package.json,tsconfig.json,cdk8s.yaml,main.ts} .
cp -r ../nextcloudkup/charts .

3. Create config.yaml

namespace: nextcloudexample
domain: example.kup6s.com
versions:
  nextcloud: "31.0.13"
  postgres: "16"
  redis: "7.4"
replicas:
  nextcloud: 1
  postgres: 2
  redis: 1

4. Generate Manifests

npm install
npm run synth
# Creates: manifests/nextcloudexample.k8s.yaml

5. Create ArgoCD Application

cd ../../../../argoapps
# Add nextcloudexample to apps config
npm run synth
git add dist/nextcloud-nextcloudexample.k8s.yaml
git commit -m "feat: add nextcloudexample instance"
git push

Total Time: ~10 minutes to deploy new instance!

Testing Strategy

Unit Tests (Future)

// packages/nextcloud-shared/test/postgres.test.ts
test('PostgresConstruct creates CNPG Cluster', () => {
  const app = Testing.app();
  const chart = new Chart(app, 'test');

  new PostgresConstruct(chart, 'postgres', {
    namespace: 'test',
    replicas: 2,
  });

  const manifest = Testing.synth(chart);
  const cluster = manifest.find(r => r.kind === 'Cluster');

  expect(cluster.spec.instances).toBe(2);
  expect(cluster.spec.storage.storageClass).toBe('longhorn');
});

Integration Tests

Deploy to test namespace and verify:

# Deploy
kubectl apply -f manifests/test.k8s.yaml

# Wait for ready
kubectl wait --for=condition=ready pod -l app=nextcloud -n test --timeout=300s

# Test
curl -I https://test.kup6s.com/status.php
# → Should return 200

Versioning Strategy

Semantic Versioning for Shared Package

// packages/nextcloud-shared/package.json
{
  "name": "@kup6s/nextcloud-shared",
  "version": "1.2.3"
}
  • Major (1.x.x): Breaking changes (API changes)

  • Minor (x.2.x): New features (new constructs)

  • Patch (x.x.3): Bug fixes

Dependency Pinning in Instances

// nextcloudkup/package.json
{
  "dependencies": {
    "@kup6s/nextcloud-shared": "workspace:*"
  }
}

NPM workspaces link to local package, not registry.