Skip to content

WP Complete Example

Franklin Garcia edited this page Dec 1, 2025 · 8 revisions

WordPress + MySQL Complete Example: Development Guide

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.

Table of Contents

  1. Overview
  2. Prerequisites
  3. Project Setup
  4. Creating the MySQL Subchart
  5. Creating the WordPress Subchart
  6. Configuring the Umbrella Chart
  7. Verification and Testing
  8. Deployment
  9. Lessons Learned

Overview

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

Prerequisites

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 timonel

Initial Project Setup

Step 1: Initialize the Umbrella Chart

Create a new umbrella chart:

tl umbrella init wordpress-stack
cd wordpress-stack

This creates the basic structure with umbrella.ts and umbrella.config.json files.

Creating the MySQL Subchart

Step 2: Add MySQL Subchart

tl umbrella add mysql

Step 3: Configure MySQL

Edit 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

Step 5: Configure 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');
}

Configuring the Umbrella Chart

Step 6: Update Umbrella Configuration

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.json stays 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"
    }
  ]
}

Step 7: Generate the Charts

tl umbrella synth

Optional: 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.

Verification and Testing

Step 8: Verify Chart Structure

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

Step 9: Test with Helm Dry Run

helm install wordpress-stack-test ./dist --dry-run --debug

Step 10: Test Template Rendering

helm template wordpress-stack ./dist

Deployment

If 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-stack

Lessons Learned

1. Critical Synthesis Calls

The 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.

2. Dependency vs Inline Mode

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.

3. Application Configuration

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.

4. Subchart Independence

Each subchart operates independently but integrates seamlessly through the umbrella chart's coordination.

5. Helm Compatibility

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.

Conclusion

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.

Clone this wiki locally