Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for AWS Service Catalog: a governed portfolio of CloudFormation-backed products, principal access via IAM, and LAUNCH constraints that let teams self-provision without over-broad permissions. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "aws" {
region = "us-east-1"
}
module "service_catalog" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-service-catalog?ref=v1.0.0"
portfolio_name = "..." # Display name of the self-service portfolio.
portfolio_provider = "..." # Owning team/org shown to end users (e.g. "Platform Engineering").
launch_role_arn = "..." # IAM role Service Catalog assumes to deploy product stacks.
products = {
# key = product name; template_url = S3 URL of the CloudFormation template.
"..." = { owner = "...", template_url = "..." }
}
principal_arns = ["..."] # IAM role/group ARNs granted access to the portfolio.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
AWS Service Catalog lets a platform team publish a curated set of approved infrastructure templates — a portfolio of products — that application teams can deploy themselves through a self-service console or API, without ever holding the IAM permissions to create the underlying resources directly. Each product is backed by a CloudFormation template; each portfolio is shared with named principals (IAM roles or groups); and a launch constraint binds a product to a dedicated IAM role that Service Catalog assumes on the user’s behalf when they provision it. That last piece is the whole point: a developer who can launch a “Compliant S3 Bucket” or “Standard VPC” product never needs s3:* or ec2:* of their own — Service Catalog deploys the stack using the constrained role, and the user only gets to fill in the allowed parameters.
Stitching this together by hand is fiddly and order-sensitive. A product and a portfolio must be associated before a constraint can reference them, the launch role’s trust policy and permissions have to line up with what the templates create, and principal associations are easy to forget so the portfolio looks empty to everyone but the admin. This module encodes the correct wiring once: it creates the portfolio, a for_each map of CloudFormation products, the product↔portfolio associations, the principal↔portfolio associations, and one LAUNCH constraint per product carrying the launch-role ARN — so adding a new paved-road product is a few lines of input, not a multi-resource dependency puzzle.
When to use it
- You run a platform/landing-zone team that wants application squads to self-serve approved infrastructure (golden VPCs, hardened buckets, baseline databases) without granting them broad IAM.
- You need guardrails on self-service: a
LAUNCHconstraint means deployments happen through a single audited role, so resources are always created with the right tags, encryption, and naming — regardless of who clicks “Launch.” - You are consolidating a sprawl of copy-pasted CloudFormation/Terraform into a governed catalog with versioned products, so teams consume a blessed template instead of forking their own.
- You want least-privilege provisioning at scale — hundreds of developers across many accounts launching the same handful of products through Service Catalog rather than holding standing create permissions.
Reach for AWS Proton instead when you want full application-platform templating with managed pipelines, or use Terraform modules shared directly when your consumers are themselves IaC-fluent platform engineers. Service Catalog shines specifically when the consumer should not hold the raw permissions and you want a console-driven, constrained, auditable launch path.
Module structure
terraform-module-aws-service-catalog/
├── versions.tf # provider + Terraform version pins
├── main.tf # portfolio, products, associations, launch constraints
├── variables.tf # var-driven inputs with validations
└── outputs.tf # portfolio id, product id map, constraint id map
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
tags = merge(
{
"ManagedBy" = "terraform"
"Module" = "terraform-module-aws-service-catalog"
},
var.tags,
)
}
# The portfolio is the unit of sharing: principals are granted access to a
# portfolio, and products are surfaced to those principals through it.
resource "aws_servicecatalog_portfolio" "this" {
name = var.portfolio_name
description = var.portfolio_description
provider_name = var.portfolio_provider
tags = local.tags
}
# One CloudFormation-backed product per entry in var.products. The
# provisioning_artifact is the "version" of the product end users launch.
resource "aws_servicecatalog_product" "this" {
for_each = var.products
name = each.key
owner = each.value.owner
type = "CLOUD_FORMATION_TEMPLATE"
description = each.value.description
distributor = each.value.distributor
support_description = each.value.support_description
support_email = each.value.support_email
support_url = each.value.support_url
provisioning_artifact_parameters {
name = each.value.artifact_name
description = each.value.artifact_description
template_url = each.value.template_url
type = "CLOUD_FORMATION_TEMPLATE"
}
tags = local.tags
}
# A product is only visible in a portfolio once associated. This must exist
# BEFORE the launch constraint, which the depends_on below guarantees.
resource "aws_servicecatalog_product_portfolio_association" "this" {
for_each = var.products
portfolio_id = aws_servicecatalog_portfolio.this.id
product_id = aws_servicecatalog_product.this[each.key].id
}
# Grant IAM principals (roles or groups) access to launch products from the
# portfolio. Without this, only the portfolio admin sees the catalog.
resource "aws_servicecatalog_principal_portfolio_association" "this" {
for_each = toset(var.principal_arns)
portfolio_id = aws_servicecatalog_portfolio.this.id
principal_arn = each.value
principal_type = "IAM"
}
# The LAUNCH constraint is the guardrail: Service Catalog assumes launch_role_arn
# to deploy the product's CloudFormation stack, so the end user never needs the
# underlying create permissions themselves.
resource "aws_servicecatalog_constraint" "launch" {
for_each = var.products
description = "Launch constraint for ${each.key}"
portfolio_id = aws_servicecatalog_portfolio.this.id
product_id = aws_servicecatalog_product.this[each.key].id
type = "LAUNCH"
parameters = jsonencode({
RoleArn = var.launch_role_arn
})
# The product/portfolio association must exist before a constraint can bind
# them, or AWS returns an error.
depends_on = [aws_servicecatalog_product_portfolio_association.this]
}
variables.tf
variable "portfolio_name" {
description = "Display name of the Service Catalog portfolio."
type = string
validation {
condition = length(var.portfolio_name) >= 1 && length(var.portfolio_name) <= 100
error_message = "portfolio_name must be 1-100 characters."
}
}
variable "portfolio_description" {
description = "Description of the portfolio shown to administrators and end users."
type = string
default = "Self-service portfolio managed by Terraform."
}
variable "portfolio_provider" {
description = "Name of the person or organization that owns the portfolio (provider_name)."
type = string
validation {
condition = length(var.portfolio_provider) >= 1 && length(var.portfolio_provider) <= 50
error_message = "portfolio_provider must be 1-50 characters."
}
}
variable "products" {
description = <<-EOT
Map of products keyed by product name. Each value:
owner - product owner shown to users (required)
template_url - S3 URL of the CloudFormation template (required)
description - product description (optional)
distributor - vendor/distributor (optional)
support_description - support information (optional)
support_email - support contact email (optional)
support_url - support contact URL (optional)
artifact_name - provisioning artifact (version) name, e.g. "v1" (default "v1")
artifact_description - description of the provisioning artifact (optional)
EOT
type = map(object({
owner = string
template_url = string
description = optional(string)
distributor = optional(string)
support_description = optional(string)
support_email = optional(string)
support_url = optional(string)
artifact_name = optional(string, "v1")
artifact_description = optional(string)
}))
validation {
condition = alltrue([
for p in values(var.products) :
can(regex("^https://", p.template_url))
])
error_message = "Every product template_url must be an https:// S3 URL to a CloudFormation template."
}
validation {
condition = length(var.products) > 0
error_message = "At least one product must be defined."
}
}
variable "principal_arns" {
description = "IAM role or group ARNs granted access to the portfolio."
type = list(string)
default = []
validation {
condition = alltrue([
for arn in var.principal_arns :
can(regex("^arn:aws[a-z-]*:iam::[0-9]{12}:(role|group)/", arn))
])
error_message = "Each principal_arns entry must be an IAM role or group ARN."
}
}
variable "launch_role_arn" {
description = "IAM role ARN that Service Catalog assumes to deploy product stacks (LAUNCH constraint RoleArn)."
type = string
validation {
condition = can(regex("^arn:aws[a-z-]*:iam::[0-9]{12}:role/", var.launch_role_arn))
error_message = "launch_role_arn must be a valid IAM role ARN."
}
}
variable "tags" {
description = "Additional tags merged onto the portfolio and products."
type = map(string)
default = {}
}
outputs.tf
output "portfolio_id" {
description = "Identifier of the Service Catalog portfolio."
value = aws_servicecatalog_portfolio.this.id
}
output "portfolio_arn" {
description = "ARN of the Service Catalog portfolio."
value = aws_servicecatalog_portfolio.this.arn
}
output "product_ids" {
description = "Map of product name => product ID (prod-xxxx)."
value = { for k, p in aws_servicecatalog_product.this : k => p.id }
}
output "product_arns" {
description = "Map of product name => product ARN."
value = { for k, p in aws_servicecatalog_product.this : k => p.arn }
}
output "constraint_ids" {
description = "Map of product name => LAUNCH constraint ID."
value = { for k, c in aws_servicecatalog_constraint.launch : k => c.id }
}
output "principal_association_ids" {
description = "Map of principal ARN => principal-portfolio association ID."
value = { for k, a in aws_servicecatalog_principal_portfolio_association.this : k => a.id }
}
How to use it
# The launch role Service Catalog assumes. Its trust policy allows the
# servicecatalog principal, and its permissions cover what the templates create.
resource "aws_iam_role" "sc_launch" {
name = "service-catalog-launch"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "servicecatalog.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "sc_launch" {
name = "service-catalog-launch"
role = aws_iam_role.sc_launch.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"cloudformation:CreateStack",
"cloudformation:DeleteStack",
"cloudformation:DescribeStackEvents",
"cloudformation:DescribeStacks",
"cloudformation:GetTemplateSummary",
"cloudformation:UpdateStack",
]
Resource = "*"
},
{
# Scope these to exactly what the products provision.
Effect = "Allow"
Action = ["s3:CreateBucket", "s3:PutEncryptionConfiguration", "s3:PutBucketTagging"]
Resource = "*"
},
]
})
}
module "service_catalog" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-service-catalog?ref=v1.0.0"
portfolio_name = "platform-paved-road"
portfolio_description = "Approved, self-service infrastructure products."
portfolio_provider = "Platform Engineering"
launch_role_arn = aws_iam_role.sc_launch.arn
products = {
"Compliant S3 Bucket" = {
owner = "Platform Engineering"
description = "Encrypted, versioned, access-logged S3 bucket."
template_url = "https://s3.amazonaws.com/kv-sc-templates/compliant-s3.yaml"
artifact_name = "v1"
artifact_description = "Initial release with SSE-KMS and versioning."
support_email = "platform@kloudvin.com"
}
"Standard Spoke VPC" = {
owner = "Platform Engineering"
description = "Three-AZ VPC with private/public subnets and NAT."
template_url = "https://s3.amazonaws.com/kv-sc-templates/standard-vpc.yaml"
support_url = "https://wiki.kloudvin.com/catalog/standard-vpc"
}
}
# Grant a developer role and an SSO-mapped group access to the catalog.
principal_arns = [
aws_iam_role.developers.arn,
"arn:aws:iam::123456789012:group/app-engineers",
]
tags = {
Environment = "shared"
Team = "platform"
CostCenter = "PLT-1001"
}
}
# Downstream: surface the portfolio ID to a sharing module that shares it to
# member accounts in the organization.
output "shared_portfolio_id" {
value = module.service_catalog.portfolio_id
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/shared/service_catalog/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-service-catalog?ref=v1.0.0"
}
inputs = {
portfolio_name = "..."
portfolio_provider = "..."
launch_role_arn = "..."
products = {
"..." = { owner = "...", template_url = "..." }
}
principal_arns = ["..."]
}
3. Deploy one environment, or roll out all modules together:
cd live/shared/service_catalog && terragrunt apply # this module
terragrunt run-all apply # every module under live/shared
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
| portfolio_name | string | — | Yes | Display name of the portfolio. |
| portfolio_provider | string | — | Yes | Owning team/org shown to end users (provider_name). |
| launch_role_arn | string | — | Yes | IAM role Service Catalog assumes to deploy product stacks. |
| products | map(object) | — | Yes | Map of CloudFormation products (owner, template_url, support fields, artifact). |
| portfolio_description | string | “Self-service portfolio managed by Terraform.” | No | Portfolio description. |
| principal_arns | list(string) | [] | No | IAM role/group ARNs granted access to the portfolio. |
| tags | map(string) | {} | No | Additional tags merged onto the portfolio and products. |
Outputs
| Name | Description |
|---|---|
| portfolio_id | Identifier of the Service Catalog portfolio. |
| portfolio_arn | ARN of the Service Catalog portfolio. |
| product_ids | Map of product name → product ID (prod-xxxx). |
| product_arns | Map of product name → product ARN. |
| constraint_ids | Map of product name → LAUNCH constraint ID. |
| principal_association_ids | Map of principal ARN → principal-portfolio association ID. |
Enterprise scenario
A platform team operating a 40-account AWS Organization wants every application squad to provision a hardened S3 bucket, a standard spoke VPC, and a baseline RDS instance themselves — but without ever holding ec2:*, s3:*, or rds:* directly. They deploy this module at v1.0.0 in the shared-services account, publishing a platform-paved-road portfolio with three CloudFormation products, each carrying a LAUNCH constraint pinned to a tightly-scoped service-catalog-launch role. Developer roles and SSO groups are added through principal_arns, and the portfolio is shared to member accounts. When a team needs a compliant bucket, they launch it from the Service Catalog console, fill in only the allowed parameters (bucket name, retention), and Service Catalog assumes the launch role to deploy the stack — so every bucket in the org is encrypted, versioned, and tagged identically, and a security audit confirms zero developers hold standing create permissions for the underlying resources.
Best practices
- Always attach a LAUNCH constraint with a least-privilege role. The whole value of Service Catalog is that the launch role — not the user — holds the create permissions. Scope
launch_role_arn’s policy to exactly what the product templates provision, and never reuse a broad admin role. - Order matters: associate before you constrain. A
LAUNCHconstraint can only bind a product and portfolio that are already associated; this module’sdepends_onenforces that, so don’t bypass it by hand-editing state. - One launch constraint per product/portfolio pair. AWS rejects multiple
LAUNCHconstraints (or aLAUNCHplusSTACKSET) on the same product/portfolio combination — keep products single-purpose so this stays clean. - Version products deliberately with provisioning artifacts. Bump
artifact_name(v1→v2) and write a clearartifact_descriptionwhen a template changes, so end users see a meaningful version history rather than a silently mutated product. - Grant access to roles and SSO groups, not individual users. Populate
principal_arnswith IAM roles and group ARNs so access tracks job function and survives staff changes — avoid pinning the catalog to named IAM users. - Tag the portfolio and products for ownership and chargeback. Carry
Environment,Team, andCostCentertags so the catalog itself, and the stacks it launches, are attributable in Cost Explorer and clear about who maintains each product.