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 templateto generate manifestsPost-processes YAML (adds sync-wave annotation)
Includes via CDK8S
Includeconstruct
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.yamldiffersConstructs 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.
Related Documentation¶
Architecture Overview - System design
How to Add Instance - Step-by-step guide
Configuration Reference - config.yaml options