DEV Community

Cover image for Creating Reusable WordPress Child Theme Templates With CI/CD
Martijn Assie
Martijn Assie

Posted on

Creating Reusable WordPress Child Theme Templates With CI/CD

Client asks: "Can you build us 5 sites with Astra but different customizations?"

Manual approach: Copy child theme 5 times, modify each one, FTP upload, repeat

Time: 6 hours × 5 sites = 30 hours!!

My approach:

  • Create reusable child theme template (once!)
  • GitHub repo with config JSON
  • GitHub Actions auto-deploys on push
  • 5 sites deployed in 20 minutes!!

Here's how to build reusable child theme templates with CI/CD automation:

Why Reusable Child Theme Templates?

Problem with manual child themes:

  • Copy/paste same code structure every project
  • Lose track of which site has which features
  • Manual FTP uploads (error-prone!)
  • Client wants changes across all sites = manual work on 10 sites!!
  • Zero consistency!!

Solution: Template system + CI/CD

  • One master template
  • Config-driven customizations
  • Git tracks everything
  • Push code → auto-deploys to all sites
  • Update 50 sites in 2 minutes!!

Architecture Overview

Three components:

1. Base Child Theme Template (GitHub repo)

my-child-theme-template/
├── .github/
│   └── workflows/
│       └── deploy.yml          # GitHub Actions
├── config/
│   ├── default.json           # Default settings
│   ├── client-a.json          # Client A config
│   └── client-b.json          # Client B config
├── src/
│   ├── functions.php
│   ├── style.css
│   └── templates/
├── gulpfile.js                # Build process
└── package.json
Enter fullscreen mode Exit fullscreen mode

2. Configuration System

JSON defines per-client customizations:

{
  "theme_name": "Client A Child",
  "parent_theme": "astra",
  "color_scheme": {
    "primary": "#FF6B35",
    "secondary": "#004E89"
  },
  "features": {
    "custom_post_types": true,
    "woocommerce": false,
    "contact_form": true
  },
  "deploy": {
    "host": "clienta.com",
    "path": "/wp-content/themes/clienta-child"
  }
}
Enter fullscreen mode Exit fullscreen mode

3. GitHub Actions CI/CD

Automatically builds and deploys on push!!

Step 1: Create Base Child Theme Template

Directory Structure

mkdir my-astra-child-template
cd my-astra-child-template
git init
Enter fullscreen mode Exit fullscreen mode

style.css (Template)

/*
Theme Name: {{THEME_NAME}}
Template: {{PARENT_THEME}}
Author: Your Agency
Version: 1.0.0
*/

:root {
  --primary-color: {{PRIMARY_COLOR}};
  --secondary-color: {{SECONDARY_COLOR}};
}

/* Your custom styles */
Enter fullscreen mode Exit fullscreen mode

Placeholders replaced by build process!!

functions.php (Template)

<?php
/**
 * Child Theme Functions
 * Generated from template
 */

// Theme setup
add_action('after_setup_theme', 'child_theme_setup');
function child_theme_setup() {
    // Load config
    $config = json_decode(file_get_contents(__DIR__ . '/config.json'), true);

    // Enqueue styles
    add_action('wp_enqueue_scripts', 'child_theme_enqueue_styles');

    // Conditional features
    if ($config['features']['custom_post_types']) {
        require_once __DIR__ . '/inc/custom-post-types.php';
    }

    if ($config['features']['woocommerce']) {
        require_once __DIR__ . '/inc/woocommerce.php';
    }
}

function child_theme_enqueue_styles() {
    wp_enqueue_style('parent-style', get_template_directory_uri() . '/style.css');
    wp_enqueue_style('child-style', get_stylesheet_uri(), ['parent-style']);
}
Enter fullscreen mode Exit fullscreen mode

package.json

{
  "name": "astra-child-template",
  "version": "1.0.0",
  "scripts": {
    "build": "node build.js",
    "deploy": "npm run build && npm run upload"
  },
  "devDependencies": {
    "replace-in-file": "^6.3.5",
    "archiver": "^5.3.1"
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Build Script (Placeholder Replacement)

build.js

const fs = require('fs');
const path = require('path');
const replace = require('replace-in-file');
const archiver = require('archiver');

// Load config
const configFile = process.env.CONFIG_FILE || 'config/default.json';
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));

console.log(`Building for: ${config.theme_name}`);

// Create build directory
const buildDir = 'build';
if (!fs.existsSync(buildDir)) {
    fs.mkdirSync(buildDir);
}

// Copy source files
const themeDir = `${buildDir}/${config.slug}`;
fs.cpSync('src', themeDir, { recursive: true });

// Replace placeholders in all files
const replacements = [
    {
        files: `${themeDir}/**/*`,
        from: /{{THEME_NAME}}/g,
        to: config.theme_name
    },
    {
        files: `${themeDir}/**/*`,
        from: /{{PARENT_THEME}}/g,
        to: config.parent_theme
    },
    {
        files: `${themeDir}/**/*`,
        from: /{{PRIMARY_COLOR}}/g,
        to: config.color_scheme.primary
    },
    {
        files: `${themeDir}/**/*`,
        from: /{{SECONDARY_COLOR}}/g,
        to: config.color_scheme.secondary
    }
];

try {
    replacements.forEach(replacement => {
        replace.sync(replacement);
    });
    console.log('✅ Placeholders replaced');
} catch (error) {
    console.error('❌ Error replacing placeholders:', error);
    process.exit(1);
}

// Copy config.json to theme
fs.copyFileSync(configFile, `${themeDir}/config.json`);

// Create ZIP file
const output = fs.createWriteStream(`${buildDir}/${config.slug}.zip`);
const archive = archiver('zip', { zlib: { level: 9 } });

output.on('close', () => {
    console.log(`✅ Created ${config.slug}.zip (${archive.pointer()} bytes)`);
});

archive.on('error', (err) => {
    throw err;
});

archive.pipe(output);
archive.directory(themeDir, config.slug);
archive.finalize();
Enter fullscreen mode Exit fullscreen mode

Run: CONFIG_FILE=config/client-a.json npm run build

Output: build/client-a-child.zip

Step 3: GitHub Actions Workflow

.github/workflows/deploy.yml

name: Deploy Child Theme

on:
  push:
    branches:
      - main
      - staging
  workflow_dispatch:
    inputs:
      config:
        description: 'Config file to deploy'
        required: true
        type: choice
        options:
          - config/client-a.json
          - config/client-b.json
          - config/client-c.json

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        config: 
          - config/client-a.json
          - config/client-b.json

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install dependencies
        run: npm install

      - name: Build theme
        run: CONFIG_FILE=${{ matrix.config }} npm run build

      - name: Load config
        id: config
        run: |
          CONFIG=$(cat ${{ matrix.config }})
          echo "host=$(echo $CONFIG | jq -r '.deploy.host')" >> $GITHUB_OUTPUT
          echo "path=$(echo $CONFIG | jq -r '.deploy.path')" >> $GITHUB_OUTPUT
          echo "slug=$(echo $CONFIG | jq -r '.slug')" >> $GITHUB_OUTPUT

      - name: Deploy via SFTP
        uses: SamKirkland/FTP-Deploy-Action@4.3.3
        with:
          server: ${{ steps.config.outputs.host }}
          username: ${{ secrets.FTP_USERNAME }}
          password: ${{ secrets.FTP_PASSWORD }}
          local-dir: ./build/${{ steps.config.outputs.slug }}/
          server-dir: ${{ steps.config.outputs.path }}/
          dangerous-clean-slate: false

      - name: Clear WordPress cache
        uses: appleboy/ssh-action@master
        with:
          host: ${{ steps.config.outputs.host }}
          username: ${{ secrets.SSH_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/html
            wp cache flush --allow-root

      - name: Notify Slack
        if: success()
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          text: ' Deployed ${{ steps.config.outputs.slug }} to ${{ steps.config.outputs.host }}'
          webhook_url: ${{ secrets.SLACK_WEBHOOK }}
Enter fullscreen mode Exit fullscreen mode

GitHub Secrets Configuration

Settings → Secrets and variables → Actions:

FTP_USERNAME: your-ftp-user
FTP_PASSWORD: your-ftp-password
SSH_USERNAME: your-ssh-user
SSH_PRIVATE_KEY: your-private-key
SLACK_WEBHOOK: your-slack-webhook-url
Enter fullscreen mode Exit fullscreen mode

Step 4: Multi-Client Configuration

config/client-a.json

{
  "theme_name": "Client A Child Theme",
  "slug": "client-a-child",
  "parent_theme": "astra",
  "color_scheme": {
    "primary": "#FF6B35",
    "secondary": "#004E89",
    "accent": "#F7C59F"
  },
  "typography": {
    "heading_font": "Montserrat",
    "body_font": "Open Sans"
  },
  "features": {
    "custom_post_types": true,
    "woocommerce": true,
    "contact_form": true,
    "custom_widgets": false
  },
  "deploy": {
    "host": "clienta.example.com",
    "path": "/public_html/wp-content/themes/client-a-child"
  }
}
Enter fullscreen mode Exit fullscreen mode

config/client-b.json

{
  "theme_name": "Client B Child Theme",
  "slug": "client-b-child",
  "parent_theme": "astra",
  "color_scheme": {
    "primary": "#1A535C",
    "secondary": "#FF6B6B",
    "accent": "#FFE66D"
  },
  "features": {
    "custom_post_types": false,
    "woocommerce": false,
    "contact_form": true,
    "custom_widgets": true
  },
  "deploy": {
    "host": "clientb.example.com",
    "path": "/public_html/wp-content/themes/client-b-child"
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Advanced Features

Conditional File Inclusion

build.js enhancement:

// Remove files based on config
if (!config.features.woocommerce) {
    // Delete WooCommerce files
    const wooFiles = [
        `${themeDir}/inc/woocommerce.php`,
        `${themeDir}/woocommerce/`
    ];
    wooFiles.forEach(file => {
        if (fs.existsSync(file)) {
            fs.rmSync(file, { recursive: true });
            console.log(`Removed: ${file}`);
        }
    });
}

if (!config.features.custom_post_types) {
    fs.rmSync(`${themeDir}/inc/custom-post-types.php`);
}
Enter fullscreen mode Exit fullscreen mode

Version Tracking

Automatic version bumping:

// In build.js
const version = config.version || '1.0.0';
const newVersion = bumpVersion(version); // 1.0.0 → 1.0.1

// Update style.css
replace.sync({
    files: `${themeDir}/style.css`,
    from: /Version: .*/,
    to: `Version: ${newVersion}`
});

// Update config
config.version = newVersion;
fs.writeFileSync(configFile, JSON.stringify(config, null, 2));
Enter fullscreen mode Exit fullscreen mode

Database Migration Scripts

migrations/001_initial_setup.php:

<?php
/**
 * Initial setup migration
 * Runs on theme activation
 */
add_action('after_switch_theme', 'child_theme_migration_001');

function child_theme_migration_001() {
    // Check if already run
    if (get_option('child_theme_migration_001')) {
        return;
    }

    // Load config
    $config = json_decode(file_get_contents(__DIR__ . '/../config.json'), true);

    // Set theme options
    set_theme_mod('primary_color', $config['color_scheme']['primary']);
    set_theme_mod('secondary_color', $config['color_scheme']['secondary']);

    // Create pages if needed
    if ($config['features']['contact_form']) {
        wp_insert_post([
            'post_title' => 'Contact',
            'post_type' => 'page',
            'post_status' => 'publish'
        ]);
    }

    // Mark as run
    update_option('child_theme_migration_001', true);
}
Enter fullscreen mode Exit fullscreen mode

Real-World Workflow

Scenario: Update All Client Sites

Bug fix in custom function:

  1. Fix code in src/functions.php
  2. Commit and push to GitHub
git add src/functions.php
git commit -m "Fix: Custom menu walker bug"
git push origin main
Enter fullscreen mode Exit fullscreen mode
  1. GitHub Actions automatically:
    • Builds for Client A
    • Builds for Client B
    • Builds for Client C
    • Deploys all 3 sites
    • Clears WordPress cache
    • Sends Slack notification

Total time: 2 minutes for 3 sites!!

Scenario: New Client Onboarding

Client D wants Astra child theme:

  1. Create config/client-d.json
{
  "theme_name": "Client D Child",
  "slug": "client-d-child",
  "parent_theme": "astra",
  "color_scheme": {
    "primary": "#6C5CE7",
    "secondary": "#A29BFE"
  },
  "features": {
    "custom_post_types": true,
    "woocommerce": true,
    "contact_form": true
  },
  "deploy": {
    "host": "clientd.example.com",
    "path": "/public_html/wp-content/themes/client-d-child"
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. Update deploy.yml matrix:
strategy:
  matrix:
    config:
      - config/client-a.json
      - config/client-b.json
      - config/client-c.json
      - config/client-d.json  # ADD THIS
Enter fullscreen mode Exit fullscreen mode
  1. Push changes
git add config/client-d.json .github/workflows/deploy.yml
git commit -m "Add Client D configuration"
git push origin main
Enter fullscreen mode Exit fullscreen mode

Client D site deployed automatically!!

Manual Deployment Option

For emergencies or testing:

# Build specific config
CONFIG_FILE=config/client-a.json npm run build

# Output: build/client-a-child.zip

# Upload manually or:
scp build/client-a-child.zip user@clienta.com:/tmp/
ssh user@clienta.com
cd /public_html/wp-content/themes
unzip /tmp/client-a-child.zip
wp cache flush
Enter fullscreen mode Exit fullscreen mode

Testing Before Deployment

Local Testing

# Build for staging
CONFIG_FILE=config/client-a-staging.json npm run build

# Copy to local WordPress
cp -r build/client-a-child ~/Local\ Sites/test-site/app/public/wp-content/themes/

# Test locally, then deploy to production
Enter fullscreen mode Exit fullscreen mode

Staging Environment

Two configs per client:

config/
├── client-a.json           # Production
├── client-a-staging.json   # Staging
Enter fullscreen mode Exit fullscreen mode

Workflow:

  1. Push to staging branch → deploys to staging
  2. Test on staging
  3. Merge to main → deploys to production

Monitoring & Rollback

Version Tags

# After successful deployment
git tag -a v1.2.3 -m "Client A deployment"
git push origin v1.2.3
Enter fullscreen mode Exit fullscreen mode

Rollback Process

# .github/workflows/rollback.yml
name: Rollback Theme

on:
  workflow_dispatch:
    inputs:
      version:
        description: 'Version to rollback to'
        required: true
      config:
        description: 'Client config'
        required: true

jobs:
  rollback:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout specific version
        uses: actions/checkout@v3
        with:
          ref: ${{ inputs.version }}

      - name: Build and deploy
        # ... same as deploy.yml
Enter fullscreen mode Exit fullscreen mode

Bottom Line

Stop copying child themes manually!!

Reusable template system wins:

  • Build once, deploy everywhere
  • Config-driven customizations
  • Git tracks all changes
  • Push code = auto-deploy
  • Update 50 sites in minutes
  • Zero FTP uploads!!

My agency workflow:

  • 1 base template
  • 27 active clients
  • Push update → all 27 sites deployed in 5 minutes
  • Before: 27 × 10 minutes = 4.5 hours per update!!

Setup time: 4 hours to build template + CI/CD

ROI: Pays for itself after 3 multi-site updates!!

For agencies managing multiple client sites: This is NON-NEGOTIABLE!!

CI/CD + child theme templates = agency superpower!! 🚀

This article contains affiliate links!

Top comments (1)

Collapse
 
martijn_assie_12a2d3b1833 profile image
Martijn Assie

Been using this exact setup for 18 months managing 23 client sites... absolute game changer. Used to spend entire afternoons FTPing child theme updates to everyone, now just push to main branch and GitHub Actions handles everything. Config JSON system is genius - same codebase, different colors/features per client. Only gotcha: make sure SSH keys are set up right or deployments fail silently...