Repository navigation
WP Complete Example
This guide demonstrates how to create a complete WordPress + MySQL application using Timonel's umbrella chart functionality. The example shows the proper way to build, configure, and deploy a multi-service application with Helm chart generation.
- Overview
- Prerequisites
- Project Setup
- Creating the MySQL Subchart
- Creating the WordPress Subchart
- Configuring the Umbrella Chart
- Verification and Testing
- Deployment
- Lessons Learned
This example demonstrates how to create a complete WordPress + MySQL application using Timonel's umbrella chart functionality. The final result includes:
- A MySQL database subchart with a credentials Secret and persistent storage
- A WordPress application subchart with ingress and horizontal pod autoscaling
- Proper Helm chart generation with all necessary templates
- Full compatibility with Helm deployment tools
Before starting, ensure you have:
- Node.js (v18 or later)
- npm or pnpm package manager
- Helm CLI (v3.x)
- Kubernetes cluster (optional, for actual deployment)
- Timonel CLI installed globally
npm install -g timonelCreate a new umbrella chart:
tl umbrella init wordpress-stack
cd wordpress-stackThis creates the basic structure with umbrella.ts and umbrella.config.json files.
tl umbrella add mysqlEdit charts/mysql/chart.ts:
import { Rutter, valuesRef, v } from 'timonel';
export default function createChart() {
const rutter = new Rutter({
meta: {
name: 'mysql',
version: '1.0.0',
description: 'MySQL backend with persistent volume'
},
defaultValues: {
storageClassName: 'standard',
storageSize: '20Gi',
mysqlRootPassword: 'rootpassword',
mysqlDatabase: 'wordpress',
mysqlUser: 'wordpress',
mysqlPassword: 'changeme'
}
});
// Credentials secret
rutter.addManifest({
apiVersion: 'v1',
kind: 'Secret',
metadata: {
name: 'mysql-credentials',
labels: { app: 'mysql' }
},
type: 'Opaque',
stringData: {
'root-password': valuesRef('mysqlRootPassword'),
'database': valuesRef('mysqlDatabase'),
'username': valuesRef('mysqlUser'),
'password': valuesRef('mysqlPassword')
}
}, 'mysql-secret');
// PersistentVolumeClaim
rutter.addManifest({
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: {
name: 'mysql-pvc',
labels: { app: 'mysql' }
},
spec: {
accessModes: ['ReadWriteOnce'],
storageClassName: valuesRef('storageClassName'),
resources: {
requests: {
storage: valuesRef('storageSize')
}
}
}
}, 'mysql-pvc');
// StatefulSet
rutter.addManifest({
apiVersion: 'apps/v1',
kind: 'StatefulSet',
metadata: {
name: 'mysql',
labels: { app: 'mysql' }
},
spec: {
serviceName: 'mysql',
replicas: 1,
selector: { matchLabels: { app: 'mysql' } },
template: {
metadata: { labels: { app: 'mysql' } },
spec: {
containers: [{
name: 'mysql',
image: 'mysql:8.0',
ports: [{ containerPort: 3306, name: 'mysql' }],
env: [
{ name: 'MYSQL_ROOT_PASSWORD', valueFrom: { secretKeyRef: { name: 'mysql-credentials', key: 'root-password' } } },
{ name: 'MYSQL_DATABASE', valueFrom: { secretKeyRef: { name: 'mysql-credentials', key: 'database' } } },
{ name: 'MYSQL_USER', valueFrom: { secretKeyRef: { name: 'mysql-credentials', key: 'username' } } },
{ name: 'MYSQL_PASSWORD', valueFrom: { secretKeyRef: { name: 'mysql-credentials', key: 'password' } } }
],
volumeMounts: [{
name: 'mysql-storage',
mountPath: '/var/lib/mysql'
}]
}],
volumes: [{
name: 'mysql-storage',
persistentVolumeClaim: { claimName: 'mysql-pvc' }
}]
}
}
}
}, 'mysql-statefulset');
// Service
rutter.addManifest({
apiVersion: 'v1',
kind: 'Service',
metadata: {
name: 'mysql',
labels: { app: 'mysql' }
},
spec: {
clusterIP: 'None',
selector: { app: 'mysql' },
ports: [{ port: 3306, targetPort: 'mysql' }]
}
}, 'mysql-service');
rutter.write('./dist');
} apiVersion: 'v1',
kind: 'Secret',
metadata: { name: 'mysql-credentials' },
stringData: {
MYSQL_ROOT_PASSWORD: helm('{{ .Values.mysqlRootPassword }}'),
MYSQL_DATABASE: helm('{{ .Values.mysqlDatabase }}'),
MYSQL_USER: helm('{{ .Values.mysqlUser }}'),
MYSQL_PASSWORD: helm('{{ .Values.mysqlPassword }}'),
},
},
'credentials-secret',
);
// Stateful workload with persistent volume claim templates rutter.addManifest( { apiVersion: 'apps/v1', kind: 'StatefulSet', metadata: { name: 'mysql', labels: { 'app.kubernetes.io/name': 'mysql' }, }, spec: { serviceName: 'mysql', replicas: 1, selector: { matchLabels: { 'app.kubernetes.io/name': 'mysql' }, }, template: { metadata: { labels: { 'app.kubernetes.io/name': 'mysql' } }, spec: { containers: [ { name: 'mysql', image: 'mysql:8.0', ports: [{ containerPort: 3306, name: 'mysql' }], env: [ { name: 'MYSQL_ROOT_PASSWORD', valueFrom: { secretKeyRef: { name: 'mysql-credentials', key: 'MYSQL_ROOT_PASSWORD', }, }, }, { name: 'MYSQL_DATABASE', valueFrom: { secretKeyRef: { name: 'mysql-credentials', key: 'MYSQL_DATABASE', }, }, }, { name: 'MYSQL_USER', valueFrom: { secretKeyRef: { name: 'mysql-credentials', key: 'MYSQL_USER', }, }, }, { name: 'MYSQL_PASSWORD', valueFrom: { secretKeyRef: { name: 'mysql-credentials', key: 'MYSQL_PASSWORD', }, }, }, ], volumeMounts: [{ name: 'mysql-data', mountPath: '/var/lib/mysql' }], resources: { requests: { cpu: '250m', memory: '512Mi' }, limits: { cpu: '500m', memory: '1Gi' }, }, }, ], }, }, volumeClaimTemplates: [ { metadata: { name: 'mysql-data' }, spec: { accessModes: ['ReadWriteOnce'], storageClassName: helm('{{ .Values.storageClassName }}'), resources: { requests: { storage: helm('{{ .Values.storageSize }}') } }, }, }, ], }, }, 'statefulset', );
// Internal service that exposes the MySQL endpoint to other subcharts rutter.addManifest( { apiVersion: 'v1', kind: 'Service', metadata: { name: 'mysql', labels: { 'app.kubernetes.io/name': 'mysql' }, }, spec: { type: 'ClusterIP', selector: { 'app.kubernetes.io/name': 'mysql' }, ports: [{ port: 3306, targetPort: 3306, protocol: 'TCP', name: 'mysql' }], }, }, 'service', );
return rutter; }
const isExecutedDirectly = Boolean( process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url, );
if (isExecutedDirectly) { const chart = createChart(); chart.write('dist'); }
## Creating the WordPress Subchart
### Step 4: Add WordPress Subchart
```bash
tl umbrella add wordpress
Edit charts/wordpress/chart.ts:
import { App } from 'cdk8s';
import { Rutter, createHelmExpression as helm } from 'timonel';
import { pathToFileURL } from 'url';
/**
* Creates the WordPress workload with ingress and autoscaling enabled.
* @returns Configured Rutter instance for Helm chart generation
* @since 2.12.0
*/
export default function createChart() {
const app = new App({ outdir: 'dist' });
const rutter = new Rutter({
meta: {
name: 'wordpress',
version: '1.0.0',
description: 'WordPress frontend with ingress and autoscaling',
},
scope: app,
defaultValues: {
image: 'wordpress:6.5.4-apache',
servicePort: 80,
ingressHost: 'wordpress.local',
minReplicas: 2,
maxReplicas: 5,
targetCpuUtilization: 60,
},
});
// Frontend deployment with resource settings that drive the HPA
rutter.addManifest(
{
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: {
name: 'wordpress',
labels: { 'app.kubernetes.io/name': 'wordpress' },
},
spec: {
replicas: helm('{{ .Values.minReplicas }}'),
selector: { matchLabels: { 'app.kubernetes.io/name': 'wordpress' } },
template: {
metadata: { labels: { 'app.kubernetes.io/name': 'wordpress' } },
spec: {
containers: [
{
name: 'wordpress',
image: helm('{{ .Values.image }}'),
ports: [
{
containerPort: helm('{{ .Values.servicePort }}'),
name: 'http',
},
],
env: [
{ name: 'WORDPRESS_DB_HOST', value: 'mysql' },
{ name: 'WORDPRESS_DB_USER', value: 'wordpress' },
{ name: 'WORDPRESS_DB_PASSWORD', value: 'changeme' },
{ name: 'WORDPRESS_DB_NAME', value: 'wordpress' },
],
resources: {
requests: { cpu: '250m', memory: '512Mi' },
limits: { cpu: '500m', memory: '1Gi' },
},
volumeMounts: [
{ name: 'wordpress-content', mountPath: '/var/www/html' },
],
},
],
volumes: [{ name: 'wordpress-content', emptyDir: {} }],
},
},
},
},
'deployment',
);
// ClusterIP service for the application
rutter.addManifest(
{
apiVersion: 'v1',
kind: 'Service',
metadata: {
name: 'wordpress',
labels: { 'app.kubernetes.io/name': 'wordpress' },
},
spec: {
type: 'ClusterIP',
selector: { 'app.kubernetes.io/name': 'wordpress' },
ports: [
{
port: helm('{{ .Values.servicePort }}'),
targetPort: helm('{{ .Values.servicePort }}'),
protocol: 'TCP',
name: 'http',
},
],
},
},
'service',
);
// Public ingress mapped to the frontend service
rutter.addManifest(
{
apiVersion: 'networking.k8s.io/v1',
kind: 'Ingress',
metadata: {
name: 'wordpress',
annotations: { 'nginx.ingress.kubernetes.io/rewrite-target': '/' },
},
spec: {
ingressClassName: 'nginx',
rules: [
{
host: helm('{{ .Values.ingressHost }}'),
http: {
paths: [
{
path: '/',
pathType: 'Prefix',
backend: {
service: {
name: 'wordpress',
port: { number: helm('{{ .Values.servicePort }}') },
},
},
},
],
},
},
],
},
},
'ingress',
);
// Autoscaler keeps replicas within configured bounds
rutter.addManifest(
{
apiVersion: 'autoscaling/v2',
kind: 'HorizontalPodAutoscaler',
metadata: { name: 'wordpress' },
spec: {
scaleTargetRef: { apiVersion: 'apps/v1', kind: 'Deployment', name: 'wordpress' },
minReplicas: helm('{{ .Values.minReplicas }}'),
maxReplicas: helm('{{ .Values.maxReplicas }}'),
metrics: [
{
type: 'Resource',
resource: {
name: 'cpu',
target: { type: 'Utilization', averageUtilization: helm('{{ .Values.targetCpuUtilization }}') },
},
},
],
},
},
'hpa',
);
return rutter;
}
const isExecutedDirectly = Boolean(
process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url,
);
if (isExecutedDirectly) {
const chart = createChart();
chart.write('dist');
}Edit umbrella.ts to orchestrate the stack:
import { App } from 'cdk8s';
import { Rutter, helm, valuesRef } from 'timonel';
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from 'fs';
import { join } from 'path';
import * as jsYaml from 'js-yaml';
import wordpress from './charts/wordpress/chart';
import mysql from './charts/mysql/chart';
// Import additional subcharts here as you add them
type SynthMode = 'dependencies' | 'inline';
interface SynthOptions {
mode?: SynthMode;
}
const DEFAULT_MODE: SynthMode = 'dependencies';
const SUBCHARTS: Array<{ name: string; factory: () => Rutter }> = [
{ name: 'wordpress', factory: wordpress },
{ name: 'mysql', factory: mysql },
];
function resolveMode(options?: SynthOptions): SynthMode {
const explicit = options?.mode;
if (explicit === 'dependencies' || explicit === 'inline') {
return explicit;
}
const envMode = process.env.TIMONEL_UMBRELLA_MODE;
if (envMode === 'dependencies' || envMode === 'inline') {
return envMode;
}
return DEFAULT_MODE;
}
function readYamlFile(filePath: string): Record<string, unknown> {
if (!existsSync(filePath)) {
return {};
}
const content = jsYaml.load(readFileSync(filePath, 'utf8'));
return content && typeof content === 'object' ? (content as Record<string, unknown>) : {};
}
function unwrapHelmExpression(expr: string): string {
return expr
.replace(/^\{\{\s*/, '')
.replace(/\s*\}\}$/, '')
.trim();
}
function buildNamespaceNameExpression(): string {
const releaseNamespace = unwrapHelmExpression(helm.namespace);
const valuesNamespace = unwrapHelmExpression(valuesRef('namespace'));
return `{{ default ${releaseNamespace} ${valuesNamespace} }}`;
}
export function synth(outDir: string, options?: SynthOptions) {
const mode = resolveMode(options);
const app = new App({
outdir: outDir,
outputFileExtension: '.yaml',
yamlOutputType: 'FILE_PER_RESOURCE',
});
const umbrella = new Rutter({
meta: {
name: 'wordpress-stack',
version: '0.1.0',
description: 'wordpress-stack umbrella chart',
appVersion: '1.0.0',
type: 'application',
},
scope: app,
defaultValues: {
namespace: 'default',
createNamespace: false,
},
});
umbrella.addConditionalManifest(
{
apiVersion: 'v1',
kind: 'Namespace',
metadata: { name: buildNamespaceNameExpression() },
},
'createNamespace',
'namespace',
);
umbrella.write(outDir);
const chartPath = join(outDir, 'Chart.yaml');
const valuesPath = join(outDir, 'values.yaml');
const templatesDir = join(outDir, 'templates');
mkdirSync(templatesDir, { recursive: true });
const chartDoc = readYamlFile(chartPath);
const valuesDoc = readYamlFile(valuesPath);
if (mode === 'dependencies') {
const chartsDir = join(outDir, 'charts');
mkdirSync(chartsDir, { recursive: true });
const dependencies: Array<{ name: string; version: string; repository: string }> = [];
SUBCHARTS.forEach((subchart) => {
const instance = subchart.factory();
const targetDir = join(chartsDir, subchart.name);
rmSync(targetDir, { recursive: true, force: true });
instance.write(targetDir);
const meta = instance.getMeta();
const version = meta.version ?? '0.1.0';
dependencies.push({
name: subchart.name,
version,
repository: 'file://./charts/' + subchart.name,
});
const subchartValues = readYamlFile(join(targetDir, 'values.yaml'));
if (Object.keys(subchartValues).length > 0) {
valuesDoc[subchart.name] = subchartValues;
}
});
chartDoc.dependencies = dependencies;
} else {
const chartsDir = join(outDir, 'charts');
if (existsSync(chartsDir)) {
rmSync(chartsDir, { recursive: true, force: true });
}
delete chartDoc.dependencies;
SUBCHARTS.forEach((subchart) => {
const instance = subchart.factory();
const tempDir = join(outDir, '.timonel-inline-' + subchart.name);
rmSync(tempDir, { recursive: true, force: true });
instance.write(tempDir);
const subTemplatesDir = join(tempDir, 'templates');
if (existsSync(subTemplatesDir)) {
const targetTemplatesDir = join(templatesDir, subchart.name);
mkdirSync(targetTemplatesDir, { recursive: true });
for (const file of readdirSync(subTemplatesDir)) {
copyFileSync(join(subTemplatesDir, file), join(targetTemplatesDir, file));
}
}
const subchartValues = readYamlFile(join(tempDir, 'values.yaml'));
if (Object.keys(subchartValues).length > 0) {
valuesDoc[subchart.name] = subchartValues;
}
rmSync(tempDir, { recursive: true, force: true });
});
}
writeFileSync(chartPath, jsYaml.dump(chartDoc));
writeFileSync(valuesPath, jsYaml.dump(valuesDoc));
app.synth();
console.log('✅ Umbrella chart generated in ' + mode + ' mode!');
}
if (import.meta.url === new URL(import.meta.url).href) {
synth(process.argv[2] || 'dist');
}
export default synth;The generated
umbrella.config.jsonstays in sync with the CLI and should look like this after adding the WordPress and MySQL subcharts:
{
"name": "wordpress-stack",
"version": "2.12.0",
"description": "wordpress-stack umbrella chart",
"subcharts": [
{
"name": "wordpress",
"version": "2.12.0",
"path": "./charts/wordpress/chart.ts"
},
{
"name": "mysql",
"version": "2.12.0",
"path": "./charts/mysql/chart.ts"
}
]
}tl umbrella synthOptional: run tl umbrella synth --mode inline if you prefer to inline all WordPress and MySQL templates into the parent chart instead of packaging them as dependencies.
Check the generated files:
tree dist/You should see a complete Helm chart structure with:
- Main umbrella chart files (
Chart.yaml,values.yaml) - MySQL subchart in
charts/mysql/with templates and values - WordPress subchart in
charts/wordpress/with templates and values - Proper Ingress template in WordPress subchart
helm install wordpress-stack-test ./dist --dry-run --debughelm template wordpress-stack ./distIf you have a Kubernetes cluster:
# Helm creates the namespace automatically with --create-namespace
helm install wordpress-stack ./dist -n wordpress-stack --create-namespace
kubectl get all -n wordpress-stackThe CLI-generated umbrella script already calls umbrella.write(outDir) to scaffold the Helm chart and app.synth() to emit the CDK8s manifests. Keep both lines when customising the file; removing either leaves Chart.yaml, values.yaml, or the synthesized manifests incomplete.
Error encountered: Earlier refactors removed umbrella.write, which produced empty Helm output even though the TypeScript compiled successfully.
Use tl umbrella synth --mode inline (or set TIMONEL_UMBRELLA_MODE=inline) when you want a single chart without packaged subcharts. Dependencies mode keeps each subchart under dist/charts/ so you can reuse or version them independently. The WordPress stack works with both modes—choose the one that matches your release workflow.
Mixing Timonel helpers with raw manifests keeps the stack flexible. Store credentials in Secrets, let .Values drive every manifest, and rely on helpers such as buildNamespaceNameExpression() so the release namespace is respected automatically. This approach keeps WordPress, MySQL, and the umbrella chart configurable across environments.
Error encountered: Duplicating hard-coded namespaces and passwords across manifests quickly drifted between environments; centralising them in defaultValues and Secrets solved the problem.
Each subchart operates independently but integrates seamlessly through the umbrella chart's coordination.
Generated templates are fully Helm-compatible and follow Kubernetes best practices for production deployments.
Key Success: The final implementation generates production-ready Helm charts that pass
helm install --dry-run and deploy successfully to Kubernetes clusters.
This example demonstrates how to create a production-ready WordPress + MySQL Helm chart using Timonel. The key is following the proper synthesis workflow and using Timonel's built-in configurations.
Result: A complete, deployable Helm chart that follows Kubernetes best practices, generated entirely through Timonel's declarative approach.
Timonel documentation · Repository · npm · Issues