IaC AWS

Terraform Module: AWS Service Catalog — A Paved-Road Self-Service Portfolio With Launch Constraints

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

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 configlive/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 configlive/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

TerraformAWSService CatalogModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading