IaC AWS

Terraform Module: AWS CodePipeline — Repeatable CI/CD release pipelines as code

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

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

TerraformAWSCodePipelineModuleIaC
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