Quick take — Build a reusable Terraform module for AWS CodePipeline: var-driven source/build/deploy stages, artifact S3 bucket, KMS encryption, and a least-privilege service role wired for production. 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 "codepipeline" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-codepipeline?ref=v1.0.0"
name = "..." # Pipeline name; also derives the artifact bucket and IAM…
stages = ["...", "..."] # Ordered stages and their actions (name, category, owner…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
AWS CodePipeline is a fully managed continuous delivery service that models your release process as an ordered set of stages, each containing one or more actions (source, build, test, deploy, approval). Each action belongs to a category and is backed by a provider — CodeStarSourceConnection for GitHub/Bitbucket, S3 for artifact sources, CodeBuild for compile/test, CloudFormation/ECS/CodeDeploy for deploy, and Manual for approval gates. CodePipeline passes typed artifacts between actions through an S3 bucket, and the whole pipeline runs under one IAM service role.
The trouble is that a hand-written aws_codepipeline resource is verbose and easy to get subtly wrong: the artifact store bucket, its KMS key, the bucket policy that lets CodePipeline write, and the service role that lets the pipeline assume CodeBuild/CodeDeploy permissions are all separate resources that must agree with each other. This module wraps aws_codepipeline together with its artifact bucket, optional customer-managed KMS encryption, and a least-privilege service role so every team ships pipelines that look identical, are encrypted at rest, and never hardcode an over-broad * role.
When to use it
- You run many microservices and want each one to get an identical source → build → deploy pipeline without copy-pasting 150 lines of HCL per repo.
- You deploy from GitHub or Bitbucket via a CodeStar connection and want the connection ARN, branch, and CodeBuild project to be simple module inputs.
- You need encryption-at-rest on build artifacts (a compliance requirement) and a manual approval gate before production deploys.
- You want the artifact S3 bucket, its lifecycle expiry, and the IAM service role created and torn down with the pipeline, not as orphaned click-ops resources.
Reach for raw resources only if you have a single bespoke pipeline with exotic cross-account actions that does not benefit from standardization.
Module structure
terraform-module-aws-codepipeline/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
# Use a customer-managed KMS key for the artifact store when one is supplied,
# otherwise fall back to the SSE-S3 / default S3 service key.
use_kms = var.kms_key_arn != null
}
# ---------------------------------------------------------------------------
# Artifact store: a dedicated, private, versioned S3 bucket per pipeline.
# ---------------------------------------------------------------------------
resource "aws_s3_bucket" "artifacts" {
bucket = "${var.name}-codepipeline-artifacts"
force_destroy = var.artifact_bucket_force_destroy
tags = var.tags
}
resource "aws_s3_bucket_public_access_block" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = local.use_kms ? "aws:kms" : "AES256"
kms_master_key_id = local.use_kms ? var.kms_key_arn : null
}
bucket_key_enabled = local.use_kms
}
}
resource "aws_s3_bucket_lifecycle_configuration" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
id = "expire-old-artifacts"
status = "Enabled"
filter {}
expiration {
days = var.artifact_retention_days
}
noncurrent_version_expiration {
noncurrent_days = var.artifact_retention_days
}
}
}
# ---------------------------------------------------------------------------
# IAM service role assumed by CodePipeline.
# ---------------------------------------------------------------------------
data "aws_iam_policy_document" "assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["codepipeline.amazonaws.com"]
}
}
}
resource "aws_iam_role" "pipeline" {
name = "${var.name}-codepipeline-role"
assume_role_policy = data.aws_iam_policy_document.assume.json
permissions_boundary = var.permissions_boundary_arn
tags = var.tags
}
data "aws_iam_policy_document" "pipeline" {
# Read/write the artifact bucket.
statement {
sid = "ArtifactBucketAccess"
effect = "Allow"
actions = [
"s3:GetObject",
"s3:GetObjectVersion",
"s3:PutObject",
"s3:GetBucketVersioning",
"s3:ListBucket",
]
resources = [
aws_s3_bucket.artifacts.arn,
"${aws_s3_bucket.artifacts.arn}/*",
]
}
# Trigger CodeBuild projects referenced by build/test actions.
statement {
sid = "CodeBuildAccess"
effect = "Allow"
actions = [
"codebuild:BatchGetBuilds",
"codebuild:StartBuild",
"codebuild:BatchGetBuildBatches",
"codebuild:StartBuildBatch",
]
resources = var.codebuild_project_arns
}
# Use the CodeStar connection for GitHub/Bitbucket sources.
dynamic "statement" {
for_each = var.codestar_connection_arn != null ? [1] : []
content {
sid = "CodeStarConnectionUse"
effect = "Allow"
actions = ["codestar-connections:UseConnection"]
resources = [var.codestar_connection_arn]
}
}
# Decrypt/encrypt artifacts when a customer-managed KMS key is used.
dynamic "statement" {
for_each = local.use_kms ? [1] : []
content {
sid = "KmsArtifactAccess"
effect = "Allow"
actions = [
"kms:Decrypt",
"kms:Encrypt",
"kms:GenerateDataKey",
"kms:DescribeKey",
]
resources = [var.kms_key_arn]
}
}
# Pass roles to deploy targets (CloudFormation, ECS, CodeDeploy, etc.).
dynamic "statement" {
for_each = length(var.passable_role_arns) > 0 ? [1] : []
content {
sid = "PassDeployRoles"
effect = "Allow"
actions = ["iam:PassRole"]
resources = var.passable_role_arns
}
}
}
resource "aws_iam_role_policy" "pipeline" {
name = "${var.name}-codepipeline-policy"
role = aws_iam_role.pipeline.id
policy = data.aws_iam_policy_document.pipeline.json
}
# ---------------------------------------------------------------------------
# The pipeline itself.
# ---------------------------------------------------------------------------
resource "aws_codepipeline" "this" {
name = var.name
role_arn = aws_iam_role.pipeline.arn
pipeline_type = var.pipeline_type
tags = var.tags
artifact_store {
location = aws_s3_bucket.artifacts.bucket
type = "S3"
dynamic "encryption_key" {
for_each = local.use_kms ? [1] : []
content {
id = var.kms_key_arn
type = "KMS"
}
}
}
dynamic "stage" {
for_each = var.stages
content {
name = stage.value.name
dynamic "action" {
for_each = stage.value.actions
content {
name = action.value.name
category = action.value.category
owner = action.value.owner
provider = action.value.provider
version = action.value.version
run_order = action.value.run_order
namespace = action.value.namespace
region = action.value.region
input_artifacts = action.value.input_artifacts
output_artifacts = action.value.output_artifacts
configuration = action.value.configuration
}
}
}
}
dynamic "trigger" {
for_each = var.pipeline_type == "V2" ? var.triggers : []
content {
provider_type = "CodeStarSourceConnection"
git_configuration {
source_action_name = trigger.value.source_action_name
dynamic "push" {
for_each = trigger.value.branches != null ? [1] : []
content {
branches {
includes = trigger.value.branches
}
}
}
}
}
}
lifecycle {
# The CodeStar source action rewrites OAuthToken-style config at runtime;
# ignore drift on the dynamic detect-changes flag managed by AWS.
ignore_changes = [stage[0].action[0].configuration["DetectChanges"]]
}
}
variables.tf
variable "name" {
description = "Name of the pipeline; also used to derive the artifact bucket and IAM role names."
type = string
validation {
condition = can(regex("^[A-Za-z0-9.@_-]{1,100}$", var.name))
error_message = "name must be 1-100 chars and only contain letters, numbers, and . @ _ - characters."
}
}
variable "pipeline_type" {
description = "Pipeline execution type. V2 enables Git triggers, variables, and parallel stage execution."
type = string
default = "V2"
validation {
condition = contains(["V1", "V2"], var.pipeline_type)
error_message = "pipeline_type must be either V1 or V2."
}
}
variable "stages" {
description = "Ordered list of pipeline stages and their actions. Must contain at least two stages (a source stage plus one more)."
type = list(object({
name = string
actions = list(object({
name = string
category = string
owner = string
provider = string
version = optional(string, "1")
run_order = optional(number, 1)
namespace = optional(string)
region = optional(string)
input_artifacts = optional(list(string), [])
output_artifacts = optional(list(string), [])
configuration = optional(map(string), {})
}))
}))
validation {
condition = length(var.stages) >= 2
error_message = "A pipeline needs at least two stages: a Source stage plus one downstream stage."
}
validation {
condition = alltrue([
for s in var.stages : contains(
["Source", "Build", "Test", "Deploy", "Approval", "Invoke", "Compute"],
s.actions[0].category
)
])
error_message = "Each action category must be one of Source, Build, Test, Deploy, Approval, Invoke, or Compute."
}
}
variable "triggers" {
description = "Git push triggers for V2 pipelines, mapping a source action to a list of branch globs."
type = list(object({
source_action_name = string
branches = optional(list(string))
}))
default = []
}
variable "codebuild_project_arns" {
description = "ARNs of CodeBuild projects the pipeline is allowed to start. Use [\"*\"] only for shared sandboxes."
type = list(string)
default = []
}
variable "codestar_connection_arn" {
description = "ARN of the CodeStar connection used by GitHub/Bitbucket source actions. Null if not using a connection."
type = string
default = null
}
variable "passable_role_arns" {
description = "Role ARNs the pipeline may iam:PassRole to deploy targets (CloudFormation, ECS, CodeDeploy)."
type = list(string)
default = []
}
variable "kms_key_arn" {
description = "Customer-managed KMS key ARN for artifact encryption. Null falls back to SSE-S3 (AES256)."
type = string
default = null
}
variable "permissions_boundary_arn" {
description = "Optional IAM permissions boundary ARN attached to the pipeline service role."
type = string
default = null
}
variable "artifact_retention_days" {
description = "Number of days before pipeline artifacts (current and noncurrent) expire from S3."
type = number
default = 30
validation {
condition = var.artifact_retention_days >= 1 && var.artifact_retention_days <= 3650
error_message = "artifact_retention_days must be between 1 and 3650."
}
}
variable "artifact_bucket_force_destroy" {
description = "Allow Terraform to delete the artifact bucket even when it still contains objects."
type = bool
default = false
}
variable "tags" {
description = "Tags applied to the pipeline, artifact bucket, and IAM role."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "The CodePipeline ID (same as the pipeline name)."
value = aws_codepipeline.this.id
}
output "name" {
description = "The name of the pipeline."
value = aws_codepipeline.this.name
}
output "arn" {
description = "The ARN of the pipeline."
value = aws_codepipeline.this.arn
}
output "role_arn" {
description = "ARN of the IAM service role the pipeline assumes."
value = aws_iam_role.pipeline.arn
}
output "role_name" {
description = "Name of the IAM service role, for attaching extra inline/managed policies."
value = aws_iam_role.pipeline.name
}
output "artifact_bucket_name" {
description = "Name of the S3 bucket holding pipeline artifacts."
value = aws_s3_bucket.artifacts.bucket
}
output "artifact_bucket_arn" {
description = "ARN of the artifact S3 bucket."
value = aws_s3_bucket.artifacts.arn
}
How to use it
module "codepipeline" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-codepipeline?ref=v1.0.0"
name = "checkout-service-prod"
pipeline_type = "V2"
codestar_connection_arn = aws_codestarconnections_connection.github.arn
codebuild_project_arns = [aws_codebuild_project.build.arn]
passable_role_arns = [aws_iam_role.ecs_deploy.arn]
kms_key_arn = aws_kms_key.artifacts.arn
artifact_retention_days = 14
stages = [
{
name = "Source"
actions = [{
name = "GitHub"
category = "Source"
owner = "AWS"
provider = "CodeStarSourceConnection"
output_artifacts = ["source_output"]
configuration = {
ConnectionArn = aws_codestarconnections_connection.github.arn
FullRepositoryId = "teknohut/checkout-service"
BranchName = "main"
DetectChanges = "true"
}
}]
},
{
name = "Build"
actions = [{
name = "Build"
category = "Build"
owner = "AWS"
provider = "CodeBuild"
input_artifacts = ["source_output"]
output_artifacts = ["build_output"]
configuration = { ProjectName = aws_codebuild_project.build.name }
}]
},
{
name = "Approve"
actions = [{
name = "ManagerSignOff"
category = "Approval"
owner = "AWS"
provider = "Manual"
configuration = {
CustomData = "Approve deployment of checkout-service to production"
}
}]
},
{
name = "Deploy"
actions = [{
name = "DeployToECS"
category = "Deploy"
owner = "AWS"
provider = "ECS"
input_artifacts = ["build_output"]
configuration = {
ClusterName = "prod-cluster"
ServiceName = "checkout-service"
FileName = "imagedefinitions.json"
}
}]
},
]
triggers = [{
source_action_name = "GitHub"
branches = ["main"]
}]
tags = {
Service = "checkout"
Environment = "prod"
}
}
# Downstream reference: a CloudWatch alarm on the pipeline using a module output,
# wired to an EventBridge rule that watches the pipeline by name.
resource "aws_cloudwatch_event_rule" "pipeline_failed" {
name = "${module.codepipeline.name}-failed"
description = "Notify on failed executions of the CodePipeline"
event_pattern = jsonencode({
source = ["aws.codepipeline"]
"detail-type" = ["CodePipeline Pipeline Execution State Change"]
detail = {
pipeline = [module.codepipeline.name]
state = ["FAILED"]
}
})
}
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/prod/codepipeline/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-codepipeline?ref=v1.0.0"
}
inputs = {
name = "..."
stages = ["...", "..."]
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/codepipeline && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
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 |
|---|---|---|---|---|
| name | string |
— | Yes | Pipeline name; also derives the artifact bucket and IAM role names. |
| pipeline_type | string |
"V2" |
No | V1 or V2; V2 enables Git triggers, variables, and parallel stages. |
| stages | list(object) |
— | Yes | Ordered stages and their actions (name, category, owner, provider, artifacts, configuration). Min 2 stages. |
| triggers | list(object) |
[] |
No | Git push triggers for V2 pipelines mapping a source action to branch globs. |
| codebuild_project_arns | list(string) |
[] |
No | CodeBuild project ARNs the pipeline may start. |
| codestar_connection_arn | string |
null |
No | CodeStar connection ARN for GitHub/Bitbucket source actions. |
| passable_role_arns | list(string) |
[] |
No | Role ARNs the pipeline may iam:PassRole to deploy targets. |
| kms_key_arn | string |
null |
No | Customer-managed KMS key ARN for artifacts; null falls back to SSE-S3. |
| permissions_boundary_arn | string |
null |
No | Optional IAM permissions boundary attached to the service role. |
| artifact_retention_days | number |
30 |
No | Days before current and noncurrent artifacts expire from S3 (1–3650). |
| artifact_bucket_force_destroy | bool |
false |
No | Allow Terraform to delete the artifact bucket while it still holds objects. |
| tags | map(string) |
{} |
No | Tags applied to the pipeline, artifact bucket, and IAM role. |
Outputs
| Name | Description |
|---|---|
| id | The CodePipeline ID (same as the pipeline name). |
| name | The name of the pipeline. |
| arn | The ARN of the pipeline. |
| role_arn | ARN of the IAM service role the pipeline assumes. |
| role_name | Name of the IAM service role, for attaching extra policies. |
| artifact_bucket_name | Name of the S3 bucket holding pipeline artifacts. |
| artifact_bucket_arn | ARN of the artifact S3 bucket. |
Enterprise scenario
A fintech platform team runs 40+ microservices across separate AWS accounts and must prove to auditors that every production release is encrypted, reviewed, and reproducible. They instantiate this module once per service in each service’s repo, passing a shared customer-managed kms_key_arn so all build artifacts are encrypted with a key whose rotation and grants are centrally governed, and a permissions_boundary_arn that caps what any pipeline role can ever do. The Approval stage gives compliance a manual sign-off before the ECS deploy runs, and the EventBridge rule on module.codepipeline.name pushes every FAILED execution into the on-call Slack channel — giving them uniform, attestable delivery across the whole fleet from a single reviewed module.
Best practices
- Scope the service role to named ARNs, not
*. Pass explicitcodebuild_project_arnsandpassable_role_arnsso the pipeline can only start the builds and assume the deploy roles it actually needs —iam:PassRoleon*is a classic privilege-escalation path. - Always encrypt artifacts with a customer-managed KMS key in regulated environments. Source code, build output, and
imagedefinitions.jsonall land in the artifact bucket; setkms_key_arnand keepblock_public_*on the bucket (the module enforces this) so artifacts are never world-readable. - Control cost with
artifact_retention_days. Versioned artifact buckets grow without bound; expiring current and noncurrent objects (default 30 days) keeps S3 spend flat. Tighten to 7–14 days for high-frequency pipelines. - Put a Manual
Approvalstage before any productionDeploy. A gated stage with meaningfulCustomDataturns a deploy into an auditable, human-acknowledged event and prevents an accidentalmainpush from shipping straight to prod. - Prefer
pipeline_type = "V2"withtriggersover polling. V2 Git triggers fire on the exact branches you list via the CodeStar connection, avoiding the per-minute source polling of V1 and giving you branch/file-path filtering for free. - Name pipelines
<service>-<environment>and tag consistently. Deriving the bucket and role names fromnamekeeps related resources discoverable, and uniformtags(Service, Environment) make cost allocation and EventBridge routing across a large fleet straightforward.