The operational friction started subtly. A new microservice request would come in, and what should have been a one-day task would stretch into a week. The process was a manual checklist of horrors: copy-pasting a Dockerfile
from another project, often inheriting outdated base images; creating a new Jenkins job by cloning and modifying an existing one, hoping you caught all the repository URL changes; and finally, hand-crafting Kubernetes manifests, a process rife with typos and misconfigured ports. Each new service was a unique artifact, a deviation from any unwritten standard, making maintenance and security audits a nightmare. This wasn’t scalable. The mounting technical debt was a direct result of process fragmentation. We needed a paved road.
Our initial concept was to build an internal scaffolding tool, a “service factory” that would automate this entire lifecycle. A developer should only need to provide a service name and a team owner. The system would then handle repository creation, pipeline setup, and the initial deployment to our development cluster. This would enforce standardization at the source. Every new service would be born from the same mold: using a hardened base image, a standard pipeline definition, and a GitOps-managed deployment configuration. There was no room for deviation in the critical path infrastructure.
The technology selection was driven by pragmatism, leveraging our existing stack where possible. For the frontend of this internal tool, Material-UI (MUI) was the obvious choice. We needed a functional, clean interface quickly, not a bespoke design. For the backend orchestrator, Express.js was chosen for its simplicity and the team’s deep familiarity with the Node.js ecosystem. It would act as the central nervous system, calling out to various APIs. For building our “golden” container images, Packer was selected. It allows us to codify the image-building process, bake in security tooling, and store versioned, immutable artifacts in our container registry. This decouples base image security from application logic. Jenkins was our incumbent CI server. While newer tools exist, its robust API for programmatic job creation made it a practical choice to integrate into our automated flow. Finally, for continuous delivery, we were already committed to a GitOps model. Argo CD was the engine driving our deployments, so the scaffolder’s final output would naturally be an Argo CD Application
manifest, committed to our GitOps repository.
The entire workflow can be visualized as a sequence of API calls and Git commits orchestrated by our central service.
sequenceDiagram participant Dev as Developer participant ScaffolderUI as Scaffolder UI (MUI) participant ScaffolderAPI as Scaffolder API (Express.js) participant GitServer as Git Server participant Jenkins as Jenkins participant ArgoCD as Argo CD participant Kubernetes as Kubernetes Cluster Dev->>ScaffolderUI: Fills out service creation form ScaffolderUI->>ScaffolderAPI: POST /api/scaffold {serviceName, team} ScaffolderAPI->>GitServer: 1. Create new service repo from template ScaffolderAPI->>Jenkins: 2. Programmatically create Jenkins job ScaffolderAPI->>GitServer: 3. Generate & commit Argo CD manifest to GitOps repo ScaffolderAPI-->>ScaffolderUI: {status: "success", argoAppLink: "..."} ScaffolderUI-->>Dev: Displays success message and link Note over ArgoCD, Kubernetes: Argo CD controller detects new Application manifest ArgoCD->>Kubernetes: 4. Syncs resources and deploys the new service
This architecture ensures a complete separation of concerns. The developer interacts with a simple UI, the Express.js service handles the complex orchestration logic, Jenkins manages the build, Packer guarantees image integrity, and Argo CD ensures the deployed state matches the desired state in Git.
Phase 1: Forging the Golden Image with Packer
Before any application code is written, we must have a secure and standardized foundation. Relying on public images like node:18-alpine
in production is a common mistake. They can contain vulnerabilities, and their contents can change without notice. We use Packer to build our own base images.
Here is the HCL template, node-base.pkr.hcl
, for our hardened Node.js image. It uses the Docker builder, pulls from a trusted base, installs common security tools like trivy
and ca-certificates
, and sets up a non-root user for security.
packer {
required_plugins {
docker = {
version = ">= 1.0.8"
source = "github.com/hashicorp/docker"
}
}
}
variable "node_version" {
type = string
default = "18.18.0"
}
variable "image_tag" {
type = string
default = "1.0.0"
}
variable "registry_url" {
type = string
// This should be set via environment variables or a secrets manager in a real pipeline
// e.g., PKRVARS_registry_url=your.registry.io
}
source "docker" "hardened-node" {
image = "node:${var.node_version}-alpine"
commit = true
changes = [
"USER appuser",
"WORKDIR /app"
]
}
build {
name = "hardened-node-image"
sources = [
"source.docker.hardened-node"
]
provisioner "shell" {
inline = [
"echo '--> Setting up non-root user'",
"apk add --no-cache shadow",
"groupadd -r appgroup && useradd -r -g appgroup -s /sbin/nologin -d /app appuser",
"echo '--> Updating packages and installing utilities'",
"apk update && apk upgrade",
"apk add --no-cache ca-certificates tini",
"echo '--> Cleaning up apk cache'",
"rm -rf /var/cache/apk/*",
"echo '--> Granting ownership of app directory'",
"mkdir -p /app && chown -R appuser:appgroup /app"
]
}
post-processor "docker-tag" {
repository = "${var.registry_url}/base/hardened-node"
tags = ["${var.node_version}-${var.image_tag}", "${var.node_version}-latest"]
}
post-processor "docker-push" {
login = true
login_server = var.registry_url
login_user = env("DOCKER_USERNAME")
login_password = env("DOCKER_PASSWORD")
}
}
This Packer build is executed in a separate Jenkins pipeline. The resulting image, like our.registry.io/base/hardened-node:18.18.0-1.0.0
, becomes the mandatory FROM
line in every new Node.js service’s Dockerfile
. This simple step centralizes a significant portion of our security posture. A vulnerability found in the base OS can be patched in one place, rebuilt, and rolled out across all services by simply bumping the version tag.
Phase 2: The Orchestration API with Express.js
The core of our automation is the Express.js backend. It exposes a single critical endpoint, /api/scaffold
, which triggers the entire workflow. This is not a simple CRUD API; it’s a stateful orchestrator that interacts with multiple external systems. Robust error handling and transactional integrity are paramount.
First, let’s set up the basic Express server and define the validation for our incoming payload. We use express-validator
to ensure the data is sane before we begin any side effects.
// file: server.js
const express = require('express');
const { body, validationResult } = require('express-validator');
const { scaffoldService } = require('./services/scaffoldingService');
const winston = require('winston');
// Basic Winston logger setup for structured logging
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.Console(),
],
});
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3000;
app.post('/api/scaffold',
// Validation middleware
body('serviceName').isLowercase().matches(/^[a-z0-9-]+$/).withMessage('Service name must be lowercase, alphanumeric with hyphens.'),
body('team').notEmpty().withMessage('Team name is required.'),
body('gitRepoUrl').isURL().withMessage('A valid Git repository URL is required.'),
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { serviceName, team, gitRepoUrl } = req.body;
const log = logger.child({ serviceName, team });
log.info('Scaffolding request received');
try {
const result = await scaffoldService({ serviceName, team, gitRepoUrl, log });
log.info('Scaffolding process completed successfully');
return res.status(201).json(result);
} catch (error) {
log.error('Scaffolding process failed', { error: error.message, stack: error.stack });
// The service layer should provide a user-friendly error message
return res.status(500).json({
message: 'Failed to scaffold service.',
error: error.message || 'An internal error occurred.'
});
}
}
);
app.listen(PORT, () => {
logger.info(`Scaffolder API listening on port ${PORT}`);
});
The real work happens in the scaffoldingService
. This service has to perform a series of operations that are not truly transactional. If step 2 fails, we must have a strategy to roll back step 1. This is a common pitfall in such orchestration systems. A simple implementation might leave orphaned resources.
Our approach is to perform read-only checks first, and then execute the write operations, logging each step. A more advanced version would implement a Saga pattern, but for this V1, we accept manual cleanup on failure.
// file: services/scaffoldingService.js
const simpleGit = require('simple-git');
const path = require('path');
const fs = require('fs').promises;
const mustache = require('mustache');
const { createJenkinsJob } = require('./jenkinsClient');
// In a real app, these would be in a config file
const TEMPLATE_REPO_URL = 'https://github.com/our-org/service-template.git';
const GITOPS_REPO_URL = 'https://github.com/our-org/gitops-manifests.git';
const GITOPS_REPO_PATH = '/tmp/gitops-repo'; // Cloned locally
const TEMP_CLONE_PATH = '/tmp/service-clone';
async function scaffoldService({ serviceName, team, gitRepoUrl, log }) {
const orchestrator = new ScaffoldingOrchestrator({ serviceName, team, gitRepoUrl, log });
try {
await orchestrator.cloneTemplate();
await orchestrator.pushToNewRepo();
await orchestrator.createJenkinsJob();
const result = await orchestrator.createArgoManifest();
return result;
} catch (err) {
// Basic cleanup attempt
log.warn('An error occurred, attempting cleanup.');
await orchestrator.cleanup();
throw err; // Re-throw to be caught by the controller
}
}
class ScaffoldingOrchestrator {
constructor({ serviceName, team, gitRepoUrl, log }) {
this.serviceName = serviceName;
this.team = team;
this.gitRepoUrl = gitRepoUrl;
this.log = log;
this.localTemplatePath = path.join(TEMP_CLONE_PATH, serviceName);
}
async cloneTemplate() {
this.log.info(`Cloning template from ${TEMPLATE_REPO_URL} to ${this.localTemplatePath}`);
await fs.rm(this.localTemplatePath, { recursive: true, force: true });
await simpleGit().clone(TEMPLATE_REPO_URL, this.localTemplatePath);
// Here you would customize files, e.g., package.json
const packageJsonPath = path.join(this.localTemplatePath, 'package.json');
const pkg = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'));
pkg.name = this.serviceName;
pkg.author = this.team;
await fs.writeFile(packageJsonPath, JSON.stringify(pkg, null, 2));
this.log.info('Template cloned and customized.');
}
async pushToNewRepo() {
this.log.info(`Pushing customized template to new repo: ${this.gitRepoUrl}`);
const git = simpleGit(this.localTemplatePath);
await git.removeRemote('origin');
await git.addRemote('origin', this.gitRepoUrl);
// Assumes PAT or SSH key is configured in the environment where this runs
await git.add('.').commit('Initial commit from scaffolder').push('origin', 'main', ['--set-upstream']);
this.log.info('Pushed to new service repository.');
}
async createJenkinsJob() {
this.log.info(`Creating Jenkins job for ${this.serviceName}`);
await createJenkinsJob(this.serviceName, this.gitRepoUrl);
this.log.info('Jenkins job created successfully.');
}
async createArgoManifest() {
this.log.info('Cloning GitOps repository to generate Argo CD manifest.');
const gitopsGit = simpleGit();
await fs.rm(GITOPS_REPO_PATH, { recursive: true, force: true });
await gitopsGit.clone(GITOPS_REPO_URL, GITOPS_REPO_PATH);
const manifestTemplate = await fs.readFile(path.join(__dirname, '../templates/argo-app.yaml.mustache'), 'utf-8');
const view = {
serviceName: this.serviceName,
team: this.team,
gitRepoUrl: this.gitRepoUrl,
// Target cluster URL
k8sServerUrl: 'https://kubernetes.default.svc',
namespace: 'dev'
};
const manifestContent = mustache.render(manifestTemplate, view);
const manifestPath = path.join(GITOPS_REPO_PATH, 'applications', `${this.serviceName}.yaml`);
await fs.writeFile(manifestPath, manifestContent);
this.log.info(`Argo CD manifest generated at ${manifestPath}`);
const gitopsRepo = simpleGit(GITOPS_REPO_PATH);
await gitopsRepo.add(manifestPath).commit(`feat(${this.serviceName}): Add new service scaffolded by IDP`).push();
this.log.info('Argo CD manifest committed and pushed to GitOps repo.');
return {
message: "Service scaffolded successfully.",
// In a real system, you'd get this from ArgoCD's API or a known URL structure
argoAppLink: `https://argocd.our-domain.com/applications/${this.serviceName}`
};
}
async cleanup() {
await fs.rm(this.localTemplatePath, { recursive: true, force: true });
await fs.rm(GITOPS_REPO_PATH, { recursive: true, force: true });
this.log.info('Temporary directories cleaned up.');
// Here you might add logic to delete the created Git repo or Jenkins job if possible
}
}
module.exports = { scaffoldService };
The Jenkins client would use the official jenkins
npm package or a simple axios
call to post a configuration XML.
// file: services/jenkinsClient.js
const jenkins = require('jenkins')({
baseUrl: `http://${process.env.JENKINS_USER}:${process.env.JENKINS_TOKEN}@${process.env.JENKINS_HOST}`,
promisify: true
});
const fs = require('fs').promises;
const path = require('path');
const mustache = require('mustache');
async function createJenkinsJob(serviceName, gitRepoUrl) {
const template = await fs.readFile(path.join(__dirname, '../templates/jenkins-config.xml.mustache'), 'utf-8');
const configXml = mustache.render(template, { gitRepoUrl });
// Check if job exists to prevent errors, a common oversight.
const jobExists = await jenkins.job.exists(serviceName);
if (jobExists) {
throw new Error(`Jenkins job '${serviceName}' already exists.`);
}
await jenkins.job.create(serviceName, configXml);
}
module.exports = { createJenkinsJob };
The templates are the heart of the standardization effort.
jenkins-config.xml.mustache
:
<?xml version='1.1' encoding='UTF-8'?>
<flow-definition plugin="[email protected]_1160">
<definition class="org.jenkinsci.plugins.workflow.cps.CpsScmFlowDefinition" plugin="[email protected]_8a_6628079d5">
<scm class="hudson.plugins.git.GitSCM" plugin="[email protected]">
<configVersion>2</configVersion>
<userRemoteConfigs>
<hudson.plugins.git.UserRemoteConfig>
<url>{{gitRepoUrl}}</url>
</hudson.plugins.git.UserRemoteConfig>
</userRemoteConfigs>
<branches>
<hudson.plugins.git.BranchSpec>
<name>*/main</name>
</hudson.plugins.git.BranchSpec>
</branches>
</scm>
<scriptPath>Jenkinsfile</scriptPath>
</definition>
</flow-definition>
argo-app.yaml.mustache
:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: {{serviceName}}
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
annotations:
owner: {{team}}
spec:
project: default
source:
repoURL: {{gitRepoUrl}}
path: k8s/overlays/dev
targetRevision: HEAD
destination:
server: {{k8sServerUrl}}
namespace: {{namespace}}
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
Phase 3: The User Interface with Material-UI
The frontend is a straightforward React application using MUI components. Its only job is to provide a clean form, handle user input and validation, and communicate with the backend API.
// file: components/ScaffoldForm.js
import React, { useState } from 'react';
import { TextField, Button, Box, Typography, CircularProgress, Alert } from '@mui/material';
import { useForm, Controller } from 'react-hook-form';
export const ScaffoldForm = () => {
const { handleSubmit, control, formState: { errors } } = useForm();
const [loading, setLoading] = useState(false);
const [apiError, setApiError] = useState(null);
const [successMessage, setSuccessMessage] = useState(null);
const onSubmit = async (data) => {
setLoading(true);
setApiError(null);
setSuccessMessage(null);
try {
const response = await fetch('/api/scaffold', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || 'API request failed');
}
setSuccessMessage(`Service created! View in Argo CD: ${result.argoAppLink}`);
} catch (error) {
setApiError(error.message);
} finally {
setLoading(false);
}
};
return (
<Box component="form" onSubmit={handleSubmit(onSubmit)} sx={{ mt: 3, width: '100%', maxWidth: 600 }}>
<Typography variant="h5" component="h1" gutterBottom>
Create a New Microservice
</Typography>
<Controller
name="serviceName"
control={control}
defaultValue=""
rules={{
required: 'Service name is required',
pattern: { value: /^[a-z0-9-]+$/, message: 'Must be lowercase, alphanumeric with hyphens' }
}}
render={({ field }) => (
<TextField
{...field}
label="Service Name"
variant="outlined"
fullWidth
margin="normal"
error={!!errors.serviceName}
helperText={errors.serviceName?.message}
/>
)}
/>
<Controller
name="team"
control={control}
defaultValue=""
rules={{ required: 'Team name is required' }}
render={({ field }) => (
<TextField
{...field}
label="Owning Team"
variant="outlined"
fullWidth
margin="normal"
error={!!errors.team}
helperText={errors.team?.message}
/>
)}
/>
<Controller
name="gitRepoUrl"
control={control}
defaultValue=""
rules={{
required: 'Git repository URL is required',
pattern: { value: /^https?:\/\/.+/, message: 'Must be a valid URL' }
}}
render={({ field }) => (
<TextField
{...field}
label="Git Repository URL (must be an empty repo)"
variant="outlined"
fullWidth
margin="normal"
error={!!errors.gitRepoUrl}
helperText={errors.gitRepoUrl?.message}
/>
)}
/>
<Box sx={{ mt: 2, position: 'relative' }}>
<Button type="submit" variant="contained" color="primary" disabled={loading} fullWidth>
Scaffold Service
</Button>
{loading && (
<CircularProgress
size={24}
sx={{
position: 'absolute',
top: '50%',
left: '50%',
marginTop: '-12px',
marginLeft: '-12px',
}}
/>
)}
</Box>
{apiError && <Alert severity="error" sx={{ mt: 2 }}>{apiError}</Alert>}
{successMessage && <Alert severity="success" sx={{ mt: 2 }}>{successMessage}</Alert>}
</Box>
);
};
This component is self-contained and provides immediate feedback to the developer, turning a complex backend process into a simple, single-click action.
This system, while not overly complex in its individual components, creates immense value through integration. The final result is a developer experience that is fast, consistent, and secure by default. The pain of manual setup is replaced by a deterministic, automated process.
The current implementation, however, is not without its limitations. The Express.js orchestrator is a single point of failure and its rollback logic is rudimentary. A more resilient architecture would use a durable message queue (like RabbitMQ or Kafka) to manage the steps of the scaffolding process, allowing for retries and proper transactional sagas. Furthermore, the reliance on cloning Git repositories locally on the API server’s filesystem is not ideal for concurrent operations and could be replaced with in-memory operations or dedicated, containerized workers. Future iterations will focus on decoupling these orchestration steps into a more robust, event-driven system and expanding the template catalog to support other languages and service types.