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
- You want a single, audited internal package endpoint for npm/pip/Maven/NuGet/Cargo instead of letting CI and developer laptops pull straight from public registries.
- You need to defend against dependency-confusion and left-pad-style supply-chain incidents by caching and pinning every external dependency inside your own KMS-encrypted domain.
- You publish private first-party libraries (an internal SDK, shared Python wheels, an org npm scope) and need a place to host them alongside proxied public packages.
- Your compliance baseline requires customer-managed KMS encryption of artifacts plus resource policies that scope publish vs. read access to specific IAM principals or accounts.
- You operate a hub-and-spoke layout where one central domain is shared across many accounts and each team gets its own repository with controlled upstreams.
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 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/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
- Always encrypt the domain with a customer-managed KMS key.
encryption_keyis set at domain creation and cannot be changed afterward, so pass a CMK you control up front — it gives you key rotation, grant auditing in CloudTrail, and revocation that the default AWS-managed key cannot. - Proxy public registries through an upstream, never connect builds to the internet directly. A single repository can hold only one
external_connection; isolating it in a dedicated upstream repo (as this module does) caches every package inside your domain, survives upstream outages, and gives you one chokepoint to audit and block dependency-confusion attacks. - Split read and publish in the resource policy. Grant
PublishPackageVersion/PutPackageMetadataonly to CI roles andReadFromRepository/GetPackageVersionAssetto the broader org — never hand outcodeartifact:*, which would let any reader overwrite first-party packages. - Centralize one domain, fan out many repositories. Because the domain deduplicates asset storage, putting all teams’ repos in a shared domain means a popular package is stored and paid for once; per-team repositories then isolate permissions and upstreams without multiplying storage cost.
- Mind the cost levers: storage, requests, and data transfer. CodeArtifact bills on stored bytes, requests, and cross-region/out transfer — keep CI and CodeArtifact in the same region, and prune unused private package versions so the domain’s deduplicated store stays lean.
- Name domains and repos by ownership, not by tool. Consistent names like
kloudvin-pkgs/team-payments-npmmake IAM conditions, cross-account domain policies, and per-format login scripts predictable as you scale to dozens of repositories.