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
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"
}
}
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
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 */
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']);
}
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"
}
}
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();
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 }}
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
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"
}
}
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"
}
}
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`);
}
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));
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);
}
Real-World Workflow
Scenario: Update All Client Sites
Bug fix in custom function:
- Fix code in
src/functions.php - Commit and push to GitHub
git add src/functions.php
git commit -m "Fix: Custom menu walker bug"
git push origin main
-
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:
- 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"
}
}
- Update
deploy.ymlmatrix:
strategy:
matrix:
config:
- config/client-a.json
- config/client-b.json
- config/client-c.json
- config/client-d.json # ADD THIS
- Push changes
git add config/client-d.json .github/workflows/deploy.yml
git commit -m "Add Client D configuration"
git push origin main
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
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
Staging Environment
Two configs per client:
config/
├── client-a.json # Production
├── client-a-staging.json # Staging
Workflow:
- Push to
stagingbranch → deploys to staging - Test on staging
- 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
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
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)
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...