Developers click one button and get a production-ready service with security, logging, alarms and scaling already built in.
README
Format: AWS CDK (TypeScript) with Service Catalog ProductStacks ยท Est. cost: platform ~$0 idle; each launched service costs what it runs (Fargate service + ALB โ $25/month)
Developers click one button (or run one CLI command) and get a production-ready service. Security, logging, alarms, tagging and autoscaling are already built in, and developers need no IAM permissions of their own.
Platform team (this CDK app) Developer
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโ
VPC + ECS cluster (shared runtime) Service Catalog console / CLI / Backstage
SSM params: /idp/vpc-id, /idp/cluster-name โโโถ "Container web service v1" โโโถ ECR + Fargate + ALB + alarms
Service Catalog portfolio "Golden Paths" "Serverless REST API v1" โโโถ HTTP API + Lambda + DynamoDB
Launch role (least privilege, no IAM users) TagOptions enforce cost-center + environment
Deploy the platform
npm install
npx cdk deploy -c developerRoleArn=arn:aws:iam::123456789012:role/AWSReservedSSO_Developer_abc123
Launch a golden path (as a developer)
Console: Service Catalog โ Products โ Container web service โ Launch. Or:
aws servicecatalog provision-product \
--product-id <ContainerServiceProductId> \
--provisioning-artifact-name v1 \
--provisioned-product-name orders-api \
--provisioning-parameters Key=ServiceName,Value=orders-api Key=OwnerEmail,Value=team@example.com
examples/provision-golden-path.yml shows the same from GitHub Actions with OIDC (no stored keys).
Ship a new version of a golden path
Add v2 to productVersions (for example add WAF), deploy, and teams upgrade with
aws servicecatalog update-provisioned-product. Platform improvements roll out as versions, not tickets.
Add Backstage (optional)
Run Backstage on the shared cluster and point a Scaffolder template at the workflow in examples/.
The Service Catalog product IDs become the catalog's "Create" actions.
Clean up
Terminate provisioned products first (Service Catalog โ Provisioned products), then:
npx cdk destroy
Files (9)
- README.md
- bin/app.ts
- lib/platform-stack.ts
- lib/products/container-service.ts
- lib/products/serverless-api.ts
- examples/provision-golden-path.yml
- cdk.json
- package.json
- tsconfig.json
# 07 ยท Internal Developer Platform with Golden Paths
**Format:** AWS CDK (TypeScript) with Service Catalog **ProductStacks** ยท **Est. cost:** platform ~$0 idle; each launched service costs what it runs (Fargate service + ALB โ $25/month)
Developers click **one button** (or run one CLI command) and get a production-ready service. Security, logging,
alarms, tagging and autoscaling are already built in, and developers need no IAM permissions of their own.
```
Platform team (this CDK app) Developer
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโ
VPC + ECS cluster (shared runtime) Service Catalog console / CLI / Backstage
SSM params: /idp/vpc-id, /idp/cluster-name โโโถ "Container web service v1" โโโถ ECR + Fargate + ALB + alarms
Service Catalog portfolio "Golden Paths" "Serverless REST API v1" โโโถ HTTP API + Lambda + DynamoDB
Launch role (least privilege, no IAM users) TagOptions enforce cost-center + environment
```
## Deploy the platform
```bash
npm install
npx cdk deploy -c developerRoleArn=arn:aws:iam::123456789012:role/AWSReservedSSO_Developer_abc123
```
## Launch a golden path (as a developer)
Console: **Service Catalog โ Products โ Container web service โ Launch**. Or:
```bash
aws servicecatalog provision-product \
--product-id <ContainerServiceProductId> \
--provisioning-artifact-name v1 \
--provisioned-product-name orders-api \
--provisioning-parameters Key=ServiceName,Value=orders-api Key=OwnerEmail,Value=team@example.com
```
`examples/provision-golden-path.yml` shows the same from GitHub Actions with OIDC (no stored keys).
## Ship a new version of a golden path
Add `v2` to `productVersions` (for example add WAF), deploy, and teams upgrade with
`aws servicecatalog update-provisioned-product`. **Platform improvements roll out as versions, not tickets.**
## Add Backstage (optional)
Run Backstage on the shared cluster and point a Scaffolder template at the workflow in `examples/`.
The Service Catalog product IDs become the catalog's "Create" actions.
## Clean up
Terminate provisioned products first (Service Catalog โ Provisioned products), then:
```bash
npx cdk destroy
```
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { PlatformStack } from '../lib/platform-stack';
const app = new cdk.App();
new PlatformStack(app, 'DeveloperPlatform', {
env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
// IAM role (e.g. your Identity Center developer role) allowed to self-serve golden paths.
developerRoleArn: app.node.tryGetContext('developerRoleArn'),
});
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as servicecatalog from 'aws-cdk-lib/aws-servicecatalog';
import * as ssm from 'aws-cdk-lib/aws-ssm';
import { ContainerServiceProduct } from './products/container-service';
import { ServerlessApiProduct } from './products/serverless-api';
export interface PlatformStackProps extends cdk.StackProps {
developerRoleArn?: string;
}
/**
* The platform team owns this stack: shared runtime (VPC + ECS cluster) and a
* Service Catalog portfolio of golden-path products developers launch themselves.
*/
export class PlatformStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: PlatformStackProps) {
super(scope, id, props);
// ---------------------------------------------------------------- shared runtime
const vpc = new ec2.Vpc(this, 'PlatformVpc', {
maxAzs: 2,
natGateways: 0, // demo-friendly: Fargate tasks get public IPs; use NAT/private subnets in production
subnetConfiguration: [{ name: 'public', subnetType: ec2.SubnetType.PUBLIC, cidrMask: 22 }],
});
const cluster = new ecs.Cluster(this, 'PlatformCluster', {
vpc,
clusterName: 'idp-shared',
containerInsightsV2: ecs.ContainerInsights.ENHANCED,
});
// Golden-path products discover the runtime through SSM parameters (a stable contract).
new ssm.StringParameter(this, 'VpcIdParam', { parameterName: '/idp/vpc-id', stringValue: vpc.vpcId });
new ssm.StringParameter(this, 'SubnetsParam', {
parameterName: '/idp/public-subnet-ids',
stringValue: vpc.publicSubnets.map((s) => s.subnetId).join(','),
});
new ssm.StringParameter(this, 'ClusterParam', { parameterName: '/idp/cluster-name', stringValue: cluster.clusterName });
// ---------------------------------------------------------------- portfolio
const portfolio = new servicecatalog.Portfolio(this, 'GoldenPaths', {
displayName: 'Golden Paths',
providerName: 'Platform Engineering',
description: 'Paved roads: production-ready services with security, logging and alarms built in.',
});
const tagOptions = new servicecatalog.TagOptions(this, 'TagOptions', {
allowedValuesForTags: {
'cost-center': ['engineering', 'data', 'product', 'research'],
environment: ['dev', 'staging', 'prod'],
},
});
portfolio.associateTagOptions(tagOptions);
// Launch role: developers never need direct IAM/ECS/ELB permissions.
const launchRole = new iam.Role(this, 'LaunchRole', {
roleName: 'idp-golden-path-launch-role',
assumedBy: new iam.ServicePrincipal('servicecatalog.amazonaws.com'),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName('AWSCloudFormationFullAccess'),
iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonECS_FullAccess'),
iam.ManagedPolicy.fromAwsManagedPolicyName('ElasticLoadBalancingFullAccess'),
iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonEC2ContainerRegistryFullAccess'),
iam.ManagedPolicy.fromAwsManagedPolicyName('CloudWatchFullAccessV2'),
iam.ManagedPolicy.fromAwsManagedPolicyName('AWSLambda_FullAccess'),
iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonAPIGatewayAdministrator'),
],
});
launchRole.addToPolicy(
new iam.PolicyStatement({
actions: [
'iam:CreateRole', 'iam:DeleteRole', 'iam:GetRole', 'iam:PassRole', 'iam:TagRole', 'iam:UntagRole',
'iam:AttachRolePolicy', 'iam:DetachRolePolicy', 'iam:PutRolePolicy', 'iam:DeleteRolePolicy', 'iam:GetRolePolicy',
'ec2:CreateSecurityGroup', 'ec2:DeleteSecurityGroup', 'ec2:AuthorizeSecurityGroupIngress',
'ec2:RevokeSecurityGroupIngress', 'ec2:AuthorizeSecurityGroupEgress', 'ec2:RevokeSecurityGroupEgress',
'ec2:CreateTags', 'ec2:Describe*', 'ssm:GetParameters', 'ssm:GetParameter',
'logs:CreateLogGroup', 'logs:DeleteLogGroup', 'logs:PutRetentionPolicy', 'logs:TagResource',
's3:GetObject',
],
resources: ['*'],
}),
);
// Products may only create roles inside a permissions boundary-friendly path.
launchRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.DENY,
actions: ['iam:CreateUser', 'iam:CreateAccessKey', 'organizations:*', 'account:*'],
resources: ['*'],
}),
);
const containerService = new servicecatalog.CloudFormationProduct(this, 'ContainerServiceProduct', {
productName: 'Container web service (ECS Fargate)',
owner: 'Platform Engineering',
description: 'Load-balanced Fargate service with ECR repo, logs, autoscaling and alarms.',
productVersions: [
{
productVersionName: 'v1',
cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(
new ContainerServiceProduct(this, 'ContainerServiceV1'),
),
},
],
});
const serverlessApi = new servicecatalog.CloudFormationProduct(this, 'ServerlessApiProduct', {
productName: 'Serverless REST API (Lambda + HTTP API)',
owner: 'Platform Engineering',
description: 'HTTP API + Lambda + DynamoDB with tracing, throttling and alarms.',
productVersions: [
{
productVersionName: 'v1',
cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(
new ServerlessApiProduct(this, 'ServerlessApiV1'),
),
},
],
});
for (const product of [containerService, serverlessApi]) {
portfolio.addProduct(product);
portfolio.setLaunchRole(product, launchRole);
}
if (props.developerRoleArn) {
portfolio.giveAccessToRole(iam.Role.fromRoleArn(this, 'DeveloperRole', props.developerRoleArn));
}
new cdk.CfnOutput(this, 'PortfolioId', { value: portfolio.portfolioId });
new cdk.CfnOutput(this, 'ContainerServiceProductId', { value: containerService.productId });
new cdk.CfnOutput(this, 'ServerlessApiProductId', { value: serverlessApi.productId });
}
}
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as ecsPatterns from 'aws-cdk-lib/aws-ecs-patterns';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as servicecatalog from 'aws-cdk-lib/aws-servicecatalog';
import * as ssm from 'aws-cdk-lib/aws-ssm';
/** Golden path #1: a production-shaped container service, launched from Service Catalog. */
export class ContainerServiceProduct extends servicecatalog.ProductStack {
constructor(scope: Construct, id: string) {
super(scope, id);
const serviceName = new cdk.CfnParameter(this, 'ServiceName', {
type: 'String',
allowedPattern: '^[a-z][a-z0-9-]{2,30}$',
description: 'Lowercase service name, e.g. orders-api',
});
const image = new cdk.CfnParameter(this, 'ContainerImage', {
type: 'String',
default: 'public.ecr.aws/nginx/nginx:stable',
description: 'Image to run first (switch to the created ECR repo after your first push)',
});
const port = new cdk.CfnParameter(this, 'ContainerPort', { type: 'Number', default: 80 });
const owner = new cdk.CfnParameter(this, 'OwnerEmail', { type: 'String', description: 'Owning team email' });
const desired = new cdk.CfnParameter(this, 'DesiredCount', { type: 'Number', default: 2, minValue: 1, maxValue: 10 });
cdk.Tags.of(this).add('service', serviceName.valueAsString);
cdk.Tags.of(this).add('owner', owner.valueAsString);
cdk.Tags.of(this).add('golden-path', 'container-service-v1');
// Shared runtime published by the platform stack.
const vpc = ec2.Vpc.fromVpcAttributes(this, 'Vpc', {
vpcId: ssm.StringParameter.valueForStringParameter(this, '/idp/vpc-id'),
availabilityZones: cdk.Fn.getAzs(),
publicSubnetIds: cdk.Fn.split(',', ssm.StringParameter.valueForStringParameter(this, '/idp/public-subnet-ids'), 2),
});
const cluster = ecs.Cluster.fromClusterAttributes(this, 'Cluster', {
clusterName: ssm.StringParameter.valueForStringParameter(this, '/idp/cluster-name'),
vpc,
});
const repo = new ecr.Repository(this, 'Repo', {
imageScanOnPush: true,
imageTagMutability: ecr.TagMutability.IMMUTABLE,
encryption: ecr.RepositoryEncryption.KMS,
lifecycleRules: [{ maxImageCount: 30 }],
emptyOnDelete: true,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
const svc = new ecsPatterns.ApplicationLoadBalancedFargateService(this, 'Service', {
cluster,
desiredCount: desired.valueAsNumber,
cpu: 256,
memoryLimitMiB: 512,
assignPublicIp: true,
taskSubnets: { subnetType: ec2.SubnetType.PUBLIC },
circuitBreaker: { enable: true, rollback: true },
runtimePlatform: { cpuArchitecture: ecs.CpuArchitecture.X86_64, operatingSystemFamily: ecs.OperatingSystemFamily.LINUX },
taskImageOptions: {
image: ecs.ContainerImage.fromRegistry(image.valueAsString),
containerPort: port.valueAsNumber,
logDriver: ecs.LogDrivers.awsLogs({
streamPrefix: 'app',
logGroup: new logs.LogGroup(this, 'Logs', { retention: logs.RetentionDays.ONE_MONTH, removalPolicy: cdk.RemovalPolicy.DESTROY }),
}),
environment: { SERVICE_NAME: serviceName.valueAsString },
},
});
repo.grantPull(svc.taskDefinition.obtainExecutionRole());
svc.targetGroup.configureHealthCheck({ path: '/', healthyHttpCodes: '200-399' });
const scaling = svc.service.autoScaleTaskCount({ minCapacity: 1, maxCapacity: 10 });
scaling.scaleOnCpuUtilization('Cpu', { targetUtilizationPercent: 60 });
new cloudwatch.Alarm(this, 'Http5xx', {
metric: svc.loadBalancer.metrics.httpCodeTarget(
cdk.aws_elasticloadbalancingv2.HttpCodeTarget.TARGET_5XX_COUNT,
{ period: cdk.Duration.minutes(1), statistic: 'Sum' },
),
threshold: 10,
evaluationPeriods: 3,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
alarmDescription: 'Golden path default: sustained 5xx from targets',
});
new cdk.CfnOutput(this, 'ServiceUrl', { value: `http://${svc.loadBalancer.loadBalancerDnsName}` });
new cdk.CfnOutput(this, 'EcrRepositoryUri', { value: repo.repositoryUri });
}
}
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as apigw from 'aws-cdk-lib/aws-apigatewayv2';
import * as integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as servicecatalog from 'aws-cdk-lib/aws-servicecatalog';
/** Golden path #2: serverless CRUD API with sane defaults (inline starter code, no assets needed). */
export class ServerlessApiProduct extends servicecatalog.ProductStack {
constructor(scope: Construct, id: string) {
super(scope, id);
const serviceName = new cdk.CfnParameter(this, 'ServiceName', {
type: 'String',
allowedPattern: '^[a-z][a-z0-9-]{2,30}$',
});
const owner = new cdk.CfnParameter(this, 'OwnerEmail', { type: 'String' });
cdk.Tags.of(this).add('service', serviceName.valueAsString);
cdk.Tags.of(this).add('owner', owner.valueAsString);
cdk.Tags.of(this).add('golden-path', 'serverless-api-v1');
const table = new dynamodb.TableV2(this, 'Table', {
partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
billing: dynamodb.Billing.onDemand(),
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
const fn = new lambda.Function(this, 'Handler', {
runtime: lambda.Runtime.PYTHON_3_12,
architecture: lambda.Architecture.ARM_64,
handler: 'index.handler',
tracing: lambda.Tracing.ACTIVE,
memorySize: 256,
timeout: cdk.Duration.seconds(10),
environment: { TABLE_NAME: table.tableName },
logGroup: new logs.LogGroup(this, 'Logs', { retention: logs.RetentionDays.ONE_MONTH, removalPolicy: cdk.RemovalPolicy.DESTROY }),
code: lambda.Code.fromInline(`
import json, os, uuid, boto3
table = boto3.resource("dynamodb").Table(os.environ["TABLE_NAME"])
def handler(event, context):
method = event["requestContext"]["http"]["method"]
item_id = (event.get("pathParameters") or {}).get("id")
if method == "POST":
item = {"id": str(uuid.uuid4()), **json.loads(event.get("body") or "{}")}
table.put_item(Item=item)
return {"statusCode": 201, "body": json.dumps(item)}
if method == "GET" and item_id:
item = table.get_item(Key={"id": item_id}).get("Item")
return {"statusCode": 200 if item else 404, "body": json.dumps(item or {"error": "not found"})}
if method == "DELETE" and item_id:
table.delete_item(Key={"id": item_id})
return {"statusCode": 204, "body": ""}
return {"statusCode": 200, "body": json.dumps(table.scan(Limit=50).get("Items", []), default=str)}
`),
});
table.grantReadWriteData(fn);
const api = new apigw.HttpApi(this, 'Api', {
description: 'Golden path serverless API',
corsPreflight: { allowOrigins: ['*'], allowMethods: [apigw.CorsHttpMethod.ANY] },
});
const integration = new integrations.HttpLambdaIntegration('Lambda', fn);
api.addRoutes({ path: '/items', methods: [apigw.HttpMethod.GET, apigw.HttpMethod.POST], integration });
api.addRoutes({ path: '/items/{id}', methods: [apigw.HttpMethod.GET, apigw.HttpMethod.DELETE], integration });
const stage = api.defaultStage!.node.defaultChild as apigw.CfnStage;
stage.defaultRouteSettings = { throttlingBurstLimit: 100, throttlingRateLimit: 50 };
new cloudwatch.Alarm(this, 'Errors', {
metric: fn.metricErrors({ period: cdk.Duration.minutes(5) }),
threshold: 5,
evaluationPeriods: 1,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});
new cdk.CfnOutput(this, 'ApiUrl', { value: api.apiEndpoint });
}
}
# Example GitHub Actions workflow a developer (or a Backstage scaffolder template) can
# drop into a new repo to self-provision a golden path through Service Catalog.
name: provision-golden-path
on:
workflow_dispatch:
inputs:
service_name: { description: 'Lowercase service name', required: true }
owner_email: { description: 'Owning team email', required: true }
permissions:
id-token: write # OIDC to AWS - no long-lived keys
contents: read
jobs:
provision:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_DEVELOPER_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- name: Launch "Container web service" golden path
run: |
PRODUCT_ID=${{ vars.CONTAINER_SERVICE_PRODUCT_ID }}
ARTIFACT_ID=$(aws servicecatalog list-provisioning-artifacts --product-id $PRODUCT_ID \
--query 'ProvisioningArtifactDetails[?Active]|[-1].Id' --output text)
aws servicecatalog provision-product \
--product-id "$PRODUCT_ID" \
--provisioning-artifact-id "$ARTIFACT_ID" \
--provisioned-product-name "${{ inputs.service_name }}" \
--provisioning-parameters \
Key=ServiceName,Value=${{ inputs.service_name }} \
Key=OwnerEmail,Value=${{ inputs.owner_email }} \
--tags Key=environment,Value=dev Key=cost-center,Value=engineering
{
"app": "npx ts-node --prefer-ts-exts bin/app.ts",
"watch": { "include": ["**"], "exclude": ["README.md", "cdk*.json", "**/*.d.ts", "**/*.js", "node_modules", "cdk.out"] },
"context": {}
}
{
"name": "07-internal-developer-platform",
"version": "1.0.0",
"private": true,
"bin": { "app": "bin/app.js" },
"scripts": {
"build": "tsc",
"synth": "cdk synth",
"deploy": "cdk deploy --all",
"destroy": "cdk destroy --all"
},
"devDependencies": {
"@types/node": "^22.0.0",
"aws-cdk": "^2.1000.0",
"ts-node": "^10.9.2",
"typescript": "~5.6.3"
},
"dependencies": {
"aws-cdk-lib": "^2.200.0",
"constructs": "^10.4.2"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["es2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"exclude": ["node_modules", "cdk.out"]
}
Build it yourself
Download the folder, run npm install (CDK) or aws cloudformation deploy (CloudFormation), and follow the README.