IaC AWS

Terraform Module: AWS CodeArtifact — KMS-encrypted package domains with locked-down upstream proxies

Quick take — A reusable Terraform module for AWS CodeArtifact: a KMS-encrypted domain plus repositories with public upstream connections (npm/PyPI/Maven), least-privilege resource policies, and external-connection proxying for private packages. 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 "codeartifact" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-codeartifact?ref=v1.0.0"

  domain_name     = "..."  # CodeArtifact domain name; lowercase, 3-50 chars, unique…
  kms_key_arn     = "..."  # Customer-managed KMS key ARN encrypting all domain asse…
  repository_name = "..."  # Primary repository tools log in to for pull/publish.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

AWS CodeArtifact is a managed, multi-format package repository for npm, PyPI, Maven, NuGet, generic, Ruby, Swift, and Cargo artifacts. Its model has two layers that almost always travel together: a domain (aws_codeartifact_domain) is the encryption-and-billing boundary that deduplicates asset storage across every repository inside it and pins them all to one KMS key, and a repository (aws_codeartifact_repository) is the per-team or per-purpose endpoint your build tools actually authenticate to with aws codeartifact login.

What makes CodeArtifact awkward to hand-roll is the wiring between repositories. A production setup almost never points builds straight at the public internet — it puts an internal repo in front of an upstream repository that holds an external_connection (public:npmjs, public:pypi, public:maven-central, …), so packages are fetched once, cached in your domain, scanned, and served from a single audited endpoint. On top of that, both the domain and each repository carry their own resource-based permissions policy, and the KMS key, the upstream chain, and those policies all have to be created in the right order or apply fails. This module wraps aws_codeartifact_domain, an optional public-proxy upstream repository, your primary repository, and both aws_codeartifact_domain_permissions_policy / aws_codeartifact_repository_permissions_policy resources into one versioned unit — so every domain comes up KMS-encrypted, every consuming repo is fronted by a cached public proxy, and access is least-privilege from the first apply with no console click-ops.

When to use it

Reach for ECR (aws_ecr_repository) instead when you are storing container images — CodeArtifact is for language-package and generic artifacts, not OCI images.

Module structure

terraform-module-aws-codeartifact/
├── 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

data "aws_caller_identity" "current" {}

locals {
  # When an external connection is requested we stand up a dedicated upstream
  # repository that holds the public proxy (a repo may carry only one external
  # connection), then chain the primary repository on top of it.
  enable_public_proxy = var.external_connection != null

  upstream_repo_name = "${var.repository_name}-upstream"

  # The primary repo's upstreams = any caller-provided internal upstreams,
  # followed by our managed public-proxy repo when enabled.
  upstreams = concat(
    var.upstreams,
    local.enable_public_proxy ? [local.upstream_repo_name] : []
  )
}

# The domain: encryption + storage-dedup boundary for all repositories within.
resource "aws_codeartifact_domain" "this" {
  domain         = var.domain_name
  encryption_key = var.kms_key_arn

  tags = var.tags
}

# Optional resource policy on the domain (e.g. allow other accounts to create
# repos / list packages within the shared domain).
resource "aws_codeartifact_domain_permissions_policy" "this" {
  count = var.domain_policy_document != null ? 1 : 0

  domain          = aws_codeartifact_domain.this.domain
  policy_document = var.domain_policy_document
}

# Dedicated upstream repository that owns the public external connection.
# Builds never hit this directly; the primary repo proxies through it so every
# external package is fetched once and cached inside the domain.
resource "aws_codeartifact_repository" "upstream" {
  count = local.enable_public_proxy ? 1 : 0

  repository  = local.upstream_repo_name
  domain      = aws_codeartifact_domain.this.domain
  description = "Public proxy (${var.external_connection}) for ${var.repository_name}"

  external_connections {
    external_connection_name = var.external_connection
  }

  tags = var.tags
}

# The primary repository your tooling authenticates to and pulls/publishes from.
resource "aws_codeartifact_repository" "this" {
  repository  = var.repository_name
  domain      = aws_codeartifact_domain.this.domain
  description = var.repository_description

  dynamic "upstream" {
    for_each = local.upstreams
    content {
      repository_name = upstream.value
    }
  }

  tags = var.tags

  # Ensure the managed upstream proxy exists before we reference it.
  depends_on = [aws_codeartifact_repository.upstream]
}

# Least-privilege resource policy on the primary repository (read vs. publish).
resource "aws_codeartifact_repository_permissions_policy" "this" {
  count = var.repository_policy_document != null ? 1 : 0

  repository      = aws_codeartifact_repository.this.repository
  domain          = aws_codeartifact_domain.this.domain
  policy_document = var.repository_policy_document
}
# variables.tf

variable "domain_name" {
  description = "CodeArtifact domain name (the encryption/storage boundary). Lowercase, must be unique within the account/region."
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{1,48}[a-z0-9]$", var.domain_name))
    error_message = "domain_name must be 3-50 chars, lowercase, start with a letter, and contain only a-z, 0-9, and hyphens."
  }
}

variable "kms_key_arn" {
  description = "ARN of a customer-managed KMS key used to encrypt all assets in the domain. Set on creation and immutable thereafter."
  type        = string

  validation {
    condition     = can(regex("^arn:aws[a-z-]*:kms:", var.kms_key_arn))
    error_message = "kms_key_arn must be a valid KMS key ARN (arn:aws:kms:...)."
  }
}

variable "repository_name" {
  description = "Name of the primary repository your build tools log in to and pull/publish from."
  type        = string

  validation {
    condition     = can(regex("^[A-Za-z0-9._-]{2,100}$", var.repository_name))
    error_message = "repository_name must be 2-100 chars using letters, numbers, and . _ - only."
  }
}

variable "repository_description" {
  description = "Human-readable description for the primary repository."
  type        = string
  default     = "Managed by Terraform"
}

variable "external_connection" {
  description = "Public registry to proxy and cache via a dedicated upstream repo (e.g. public:npmjs, public:pypi, public:maven-central). Null disables the public proxy."
  type        = string
  default     = null

  validation {
    condition = var.external_connection == null || contains([
      "public:npmjs",
      "public:pypi",
      "public:maven-central",
      "public:maven-googleandroid",
      "public:maven-gradleplugins",
      "public:maven-commonsware",
      "public:maven-clojars",
      "public:nuget-org",
      "public:ruby-gems-org",
      "public:crates-io",
    ], var.external_connection)
    error_message = "external_connection must be null or a supported public:<registry> connection name."
  }
}

variable "upstreams" {
  description = "Names of additional internal repositories (within the same domain) to chain as upstreams, ordered by search priority. The managed public proxy is appended automatically when external_connection is set."
  type        = list(string)
  default     = []
}

variable "domain_policy_document" {
  description = "Optional JSON resource policy attached to the domain (e.g. cross-account CreateRepository / ListRepositories grants). Null to omit."
  type        = string
  default     = null
}

variable "repository_policy_document" {
  description = "Optional JSON resource policy attached to the primary repository (scope ReadFromRepository vs. PublishPackageVersion to principals). Null to omit."
  type        = string
  default     = null
}

variable "tags" {
  description = "Tags applied to the domain and all repositories."
  type        = map(string)
  default     = {}
}
# outputs.tf

output "domain_name" {
  description = "The name of the CodeArtifact domain."
  value       = aws_codeartifact_domain.this.domain
}

output "domain_arn" {
  description = "ARN of the CodeArtifact domain."
  value       = aws_codeartifact_domain.this.arn
}

output "domain_owner" {
  description = "AWS account ID that owns the domain (used as --domain-owner in CLI logins)."
  value       = aws_codeartifact_domain.this.owner
}

output "repository_name" {
  description = "Name of the primary repository to use with 'aws codeartifact login'."
  value       = aws_codeartifact_repository.this.repository
}

output "repository_arn" {
  description = "ARN of the primary repository."
  value       = aws_codeartifact_repository.this.arn
}

output "upstream_repository_name" {
  description = "Name of the managed public-proxy upstream repository, or null when no external connection is configured."
  value       = try(aws_codeartifact_repository.upstream[0].repository, null)
}

How to use it

module "codeartifact" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-codeartifact?ref=v1.0.0"

  domain_name = "kloudvin-pkgs"
  kms_key_arn = aws_kms_key.codeartifact.arn

  repository_name        = "team-payments-npm"
  repository_description  = "npm packages for the payments domain"

  # Front the repo with a cached, audited proxy of the public npm registry.
  external_connection = "public:npmjs"

  # Lock down: CI publish role may publish, everyone in the org may read.
  repository_policy_document = data.aws_iam_policy_document.repo_access.json

  tags = {
    Environment = "prod"
    Team        = "payments"
    ManagedBy   = "terraform"
  }
}

data "aws_iam_policy_document" "repo_access" {
  statement {
    sid    = "PublishFromCI"
    effect = "Allow"
    principals {
      type        = "AWS"
      identifiers = [aws_iam_role.ci_publish.arn]
    }
    actions = [
      "codeartifact:PublishPackageVersion",
      "codeartifact:PutPackageMetadata",
    ]
    resources = ["*"]
  }

  statement {
    sid    = "ReadForOrg"
    effect = "Allow"
    principals {
      type        = "AWS"
      identifiers = [data.aws_caller_identity.current.account_id]
    }
    actions = [
      "codeartifact:ReadFromRepository",
      "codeartifact:GetPackageVersionAsset",
    ]
    resources = ["*"]
  }
}

# Downstream: a CodeBuild project authenticates to the repository at build time
# using the domain owner + repo name emitted by the module.
resource "aws_codebuild_project" "payments_api" {
  name         = "payments-api"
  service_role = aws_iam_role.codebuild.arn

  artifacts { type = "NO_ARTIFACTS" }

  environment {
    compute_type    = "BUILD_GENERAL1_SMALL"
    image           = "aws/codebuild/standard:7.0"
    type            = "LINUX_CONTAINER"

    environment_variable {
      name  = "CA_DOMAIN_OWNER"
      value = module.codeartifact.domain_owner
    }
    environment_variable {
      name  = "CA_REPOSITORY"
      value = module.codeartifact.repository_name
    }
    environment_variable {
      name  = "CA_DOMAIN"
      value = module.codeartifact.domain_name
    }
  }

  source {
    type      = "GITHUB"
    location  = "https://github.com/kloudvin/payments-api.git"
    # buildspec runs: aws codeartifact login --tool npm \
    #   --domain $CA_DOMAIN --domain-owner $CA_DOMAIN_OWNER --repository $CA_REPOSITORY
    buildspec = "buildspec.yml"
  }
}

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/codeartifact/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-codeartifact?ref=v1.0.0"
}

inputs = {
  domain_name = "..."
  kms_key_arn = "..."
  repository_name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/codeartifact && 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
domain_name string Yes CodeArtifact domain name; lowercase, 3-50 chars, unique per account/region.
kms_key_arn string Yes Customer-managed KMS key ARN encrypting all domain assets; immutable after creation.
repository_name string Yes Primary repository tools log in to for pull/publish.
repository_description string "Managed by Terraform" No Description for the primary repository.
external_connection string null No Public registry to proxy/cache (e.g. public:npmjs, public:pypi); null disables the proxy.
upstreams list(string) [] No Additional internal upstream repos in the domain, ordered by search priority.
domain_policy_document string null No JSON resource policy on the domain (e.g. cross-account grants); null to omit.
repository_policy_document string null No JSON resource policy on the primary repo (read vs. publish scoping); null to omit.
tags map(string) {} No Tags applied to the domain and all repositories.

Outputs

Name Description
domain_name The CodeArtifact domain name.
domain_arn ARN of the domain.
domain_owner AWS account ID owning the domain (used as --domain-owner at login).
repository_name Primary repository name for aws codeartifact login.
repository_arn ARN of the primary repository.
upstream_repository_name Managed public-proxy upstream repo name, or null when no external connection is set.

Enterprise scenario

A fintech platform team runs one shared kloudvin-pkgs domain encrypted with a central KMS CMK, then uses a for_each over this module to give each squad its own repository — team-payments-npm, team-risk-pypi, team-core-maven — each fronted by the matching public: external connection. CI publish roles (assumed via GitHub Actions OIDC) get scoped PublishPackageVersion through repository_policy_document, while every build server reads through the cached proxy, so a public-registry outage or a yanked package never breaks a deploy and every third-party dependency that enters the estate is recorded in one auditable, KMS-encrypted place — closing the dependency-confusion gap their security team flagged.

Best practices

TerraformAWSCodeArtifactModuleIaC
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