The situation
A platform team has inherited a package-management mess.
- Open-source dependencies come from
https://registry.npmjs.org/andhttps://pypi.org/simple/directly. If either is slow or down, every build fails. There is no cache. - Internal packages, four TypeScript libraries, two Python libraries, one Go module, are published to a mix of private npm (one team), a GitHub Packages registry (another), and a
git+ssh://repo URL (a third). Provenance is whoever had credentials. - Security wants every dependency the company uses to be pullable from a registry the company controls; a way to ban specific open-source packages; a tamper-evident trail of who published what; read and publish permissions handled through IAM rather than long-lived registry tokens.
The ask is to consolidate into one managed package registry with an upstream proxy for public packages, an internal-publish repository for company packages, and IAM-based access.
What actually matters
Before reaching for a service, it helps to think about what “a package registry” actually has to do inside an organisation, because the word covers very different systems once the requirements list gets long.
The first property worth naming is ownership of the bytes. If every build in the company pulls lodash directly from npmjs.org, then the availability of every build is hostage to a registry the company doesn’t run. A package-registry strategy that doesn’t cache the upstream is a strategy that says “we accept public-registry outages as our outages.” The opposite extreme, mirroring all of npmjs.org preemptively, is wasteful. The honest middle is lazy caching: fetch on first request, serve from cache thereafter, and accept that the first build after a public outage might fail.
The second is blast radius when something goes wrong. Public-registry incidents happen in two shapes: unavailability (a package can’t be fetched) and compromise (a package is replaced with something malicious). A registry the company controls addresses both, but only if the controls exist. Lazy-caching a compromised package is exactly as bad as pulling it fresh; what changes is the policy surface, the company can ban a package at its own registry in seconds, where changing every build’s package.json takes days.
The third is the cost shape of commitment to a registry platform. Running a self-hosted registry (Artifactory, Nexus, Verdaccio) buys full control and a bill that scales with operator time, storage, HA posture, and the inevitable disaster-recovery story. A managed registry, particularly one native to the cloud provider, buys less control and a bill that scales with storage and requests. The thing worth pinning down is what “full control” is actually being used for. For most teams, the answer is “nothing we couldn’t get from a managed service”; for a few, the answer is “custom plugins and air-gapped networks”, and that’s a different conversation.
The fourth is recovery when access breaks. Long-lived registry tokens pinned into CI configs are the default for self-hosted registries, and they’re also the source of most “we rotated the token and fourteen pipelines broke on Monday” stories. A registry whose credentials are short-lived and issued per-build from the cloud’s IAM system collapses a whole category of operational incidents, because there is no token to leak and no rotation to forget. The cost is the plumbing, every build has to fetch a fresh token, but modern CI makes that a single command.
The fifth is observability of who did what. Publishes are the events that matter most: someone pushed a new version of an internal library, and that version is now available to every consumer. The trail needs to include the principal, the package, the version, and the time, and it needs to survive the team that published forgetting they did. An audit log that’s baked into the platform is qualitatively different from one the team has to remember to configure.
The sixth is coupling to the package-manager clients. Developers will run npm install, pip install, mvn deploy, and cargo publish against whatever registry the company picks. A registry that speaks each client’s native protocol means zero change to tooling on the developer’s machine; a registry that requires a custom plugin or a special client means ongoing friction on every new hire’s laptop. The value of “looks like npm, looks like PyPI” compounds with team size.
What we’ll filter on
Distilling that exploration into filters worth scoring each option against:
- Upstream proxy with lazy caching, does the registry fetch from the public registry on cache miss and serve from cache afterwards?
- Per-format native protocol, npm, pip, Maven, NuGet, cargo, etc. all talk to it with no exotic client.
- IAM-based short-lived credentials, temporary tokens issued per build, no long-lived registry passwords.
- Policy-level package blocking, bans happen at the registry, not in each build config.
- Operational ownership, who runs the control plane, data plane, upgrades, DR.
- Cross-account sharing, multiple AWS accounts (dev, staging, prod, per-team) can read from the same registry.
The package-hosting landscape
-
Pull directly from public registries. The status quo.
npm,pip,maven, etc. reach out to their public endpoints. Zero operational overhead, zero control. Outages propagate; compromises propagate; there is no cache and no policy surface. Rules itself out the moment “dependency the company controls” becomes a requirement. -
Self-hosted JFrog Artifactory or Sonatype Nexus on EC2/EKS. Full-featured, multi-format, runs on the company’s own infrastructure. Deep control: custom plugins, air-gapped deployments, bespoke retention, feature parity across every package format under the sun. Licence cost for Artifactory Pro; operational cost for both. HA needs more than one instance, and the database is the chewy centre. Fits when the company has a strong self-host culture or genuinely needs features the managed options don’t have.
-
GitHub Packages. Packages live alongside the source repository. Works for npm, Maven, NuGet, RubyGems, Docker, and others. Authentication uses GitHub tokens, which are long-lived unless actively managed, and the IAM story is GitHub’s, separate from AWS IAM. Fine for open-source publishing; awkward as the centre of an AWS-native compliance story.
-
Cloudsmith, packagecloud, or Gemfury. Third-party SaaS registries. Multi-format, hosted, per-seat or per-traffic billing. Trades one “registry we don’t run” for another, except we’re paying them and the terms are commercial rather than community. Viable, but doesn’t close the “pullable from a registry the company controls” gap unless the company owns the relationship.
-
Amazon S3 plus a static index. A minimal pattern: push package tarballs to a versioned S3 bucket and expose an index that each package manager can read. Works for simple cases (Python, Ruby, Go modules), harder for registries that expect rich metadata (npm). No upstream proxy, no policy beyond IAM on the bucket. Cheap and tempting; grows legs quickly.
-
AWS CodeArtifact. Managed package registry from AWS. Supports npm, pip, Maven, NuGet, generic, Swift, Ruby, and cargo (with the catalogue still growing). Native upstream connectors to public registries (npmjs, PyPI, Maven Central, NuGet.org, etc.) provide lazy caching. IAM-based access via
GetAuthorizationToken; resource policies per repository for publish and read control. KMS-encrypted storage per domain. CloudTrail for publishes and token fetches. Cross-account sharing is a domain-policy change.
Side by side
| Option | Upstream proxy | Native protocols | IAM short-lived | Package-level block | Operational load | Cross-account |
|---|---|---|---|---|---|---|
| Direct public registries | ✗ | ✓ | ✗ | ✗ | None | n/a |
| Self-hosted Artifactory/Nexus | ✓ | ✓ | ✗ | ✓ | High | Via auth |
| GitHub Packages | ✗ | Partial | ✗ | ✗ | None | Via GH org |
| SaaS registry (Cloudsmith etc.) | ✓ | ✓ | ✗ | ✓ | Low | Via API key |
| S3 + static index | ✗ | Partial | ✓ | ✗ | Medium | Bucket policy |
| CodeArtifact | ✓ | ✓ | ✓ | ✓ | Low | Domain policy |
Reading the table, only two options clear every column the security ask implies, and the remaining dimension, operational load, splits them cleanly. Self-hosted Artifactory earns its keep when the company needs its particular feature surface; CodeArtifact earns its keep when the company wants the shape without the operator cost.
Matching the hosting choice to the constraints
CodeArtifact, in depth
CodeArtifact has three primitives that repay understanding before drawing any diagrams.
-
Domain. A logical grouping of repositories with a single KMS key, a single set of credentials, and cross-account sharing scope. Most companies have one domain per organisation (e.g.
acme). Packages are deduplicated across repositories in the same domain: if two repositories both servelodash@4.17.21, the package bytes exist once in the domain’s storage. -
Repository. A named collection of packages within a domain. Repositories have an upstream list, other repositories (including a special “public connector” that points at the external registry) consulted in order on cache miss. Authorisation is per-repository: an IAM principal can be allowed to read from one repository and publish to another.
-
Package and package version. A
format: npm | maven | pypi | nuget | generic | swift | ruby | cargo | (plus a growing list)resource under a repository. Each version has an origin (whether it was published directly or pulled through upstream) and a status (active, unfinished, unlisted, archived, disposed). The origin matters: a package version with originEXTERNALfrom the upstream can be deleted and re-fetched; one with originINTERNALis the company’s own publication and shouldn’t be replaced.
The clean pattern is three repositories per format.
npm-store, upstream is the public npm connector. Nothing is published here directly; everything is pulled fromnpmjs.orgon demand. Caches public packages.npm-internal, where internal packages are published. No upstream to the public connector directly; publish-restricted to the platform team.npm-shared, aggregates the other two. Upstreams arenpm-internal(first) andnpm-store(second). Build systems read fromnpm-shared. A request walks itself, thennpm-internal, thennpm-store(which walks its upstream tonpmjs.orgon cache miss). One read URL for everything; three layers for policy.
Replicate the three-repository shape per format: pypi-store + pypi-internal + pypi-shared, same for Maven, NuGet, etc.
IAM and the authorisation token. Authorisation is a two-step process worth naming precisely. The principal calls codeartifact:GetAuthorizationToken on the domain; IAM evaluates, returns a short-lived (up to 12 hours, default 12) bearer token. The principal uses the token as the password against the repository endpoint, a standard HTTPS URL that looks native to each package manager. npm, yarn, pnpm, pip, Maven, cargo authenticate with the token and make normal package-manager requests. CI pipelines fetch a token at the start of a build and discard it when the build ends.
Repository policies gate publish: a resource policy on npm-internal restricts codeartifact:PublishPackageVersion to the platform team’s role; read is open. Domain policies cover cross-account sharing, another account’s roles can be granted access to the domain, which lets them fetch tokens and read from shared repositories.
Gotchas.
- Upstream order matters. Putting
storebeforeinternalinshared’s upstream list means a public package shadowing an internal one of the same name gets served first. The internal-first order prevents dependency confusion attacks. - The connector is per-repository, not per-domain. Each
*-storerepository needs its own upstream connector configured; forget one and that format’s builds hit the public registry directly (or fail, depending on the package manager’s fallback). - Origin controls determine which versions can be imported from upstream. When a version with origin
INTERNALexists in the domain, CodeArtifact refuses to import a same-named upstream version, protection against someone publishing a malicious@acme/core-libstonpmjs.org. Origin controls are worth auditing per-repository. - Package-manager lockfiles pin registry URLs. A
package-lock.jsonwritten againstnpmjs.orgstill referencesnpmjs.orgas the resolved URL; regenerate lockfiles after switching tonpm-sharedor reads will bypass the cache. - Token expiry matters for long builds. A multi-hour build that took a 12-hour token at the start is fine; a build that shells out to another tool later might pick up a stale token from the environment.
aws codeartifact logininside each stage is cheap insurance.
A worked example: publishing and consuming
Publishing an internal package (@acme/core-libs).
aws codeartifact login --tool npm \
--domain acme --repository npm-internal \
--namespace @acme
npm publish
The login writes the auth token to .npmrc and sets the registry URL for the @acme scope to npm-internal’s endpoint. npm publish POSTs the package to that URL. IAM evaluates codeartifact:PublishPackageVersion on the repository; success means the package appears in npm-internal and is visible through npm-shared on the next read.
Consuming packages in CI.
aws codeartifact login --tool npm \
--domain acme --repository npm-shared
npm ci
The login writes the token and points the registry at npm-shared. npm ci resolves @acme/core-libs through npm-internal (cache hit) and lodash through npm-store (likely cache hit, or cache miss pulling from npmjs.org). The CI role needs codeartifact:GetAuthorizationToken on the domain and codeartifact:ReadFromRepository on npm-shared; it doesn’t need any permission on npm-internal or npm-store directly, because the reads go through npm-shared’s upstream chain.
Blocking a package. A repository policy on npm-store denying codeartifact:ReadFromRepository for a specific package name keeps an abandoned library from ever caching into the domain:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Deny",
"Principal": "*",
"Action": "codeartifact:ReadFromRepository",
"Resource": "arn:aws:codeartifact:eu-west-1:111122223333:package/acme/npm-store/npm//abandoned-crypto-lib"
}]
}
The build fails with “package not found” from CodeArtifact’s side; the developer looks for a maintained alternative.
Cross-account reads. For a multi-account landing zone, share the domain with member accounts and scope which repositories they can read:
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowCrossAccountRead",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::222222222222:root" },
"Action": [
"codeartifact:GetAuthorizationToken",
"codeartifact:ReadFromRepository"
],
"Resource": [
"arn:aws:codeartifact:eu-west-1:111122223333:domain/acme",
"arn:aws:codeartifact:eu-west-1:111122223333:repository/acme/npm-shared",
"arn:aws:codeartifact:eu-west-1:111122223333:repository/acme/pypi-shared"
]
}]
}
Account 222222222222 gets read-only access to the shared repositories; publish remains scoped to the source account’s platform team.
What’s worth remembering
- A company-controlled registry is about ownership of the bytes, not about being closer to the developer. The point is that a public-registry outage or compromise doesn’t reach production automatically.
- CodeArtifact has domains, repositories, and packages. The domain holds the KMS key and deduplicates; repositories are the unit of access control; packages have origins and statuses.
- The clean pattern is three repositories per format.
internalfor publish,storefor upstream caching,sharedas the unified read endpoint. Publish tointernal; read fromshared. - Upstream order is the guardrail.
internalbeforestorestops a public package shadowing an internal one of the same name, the dependency-confusion guardrail. - Authorisation is a two-step token.
codeartifact:GetAuthorizationTokenreturns a short-lived bearer; package managers use it as the password against the repository URL. No long-lived registry tokens live anywhere. - Repository policies gate publish. Platform team gets
codeartifact:PublishPackageVersiononinternal; everyone else gets read. Build roles need only the domain token + read on thesharedrepository. - Block upstream packages with deny statements. A resource policy denying read on a specific package ARN bans it from the domain; the build fails at the registry instead of at code review.
- Cross-account access is a domain policy. Share the domain with member accounts; grant read on shared repositories; publish stays in the source account.
- CloudTrail records publishes and token fetches. Every
PublishPackageVersionandGetAuthorizationTokenappears with the caller’s principal, the audit trail is automatic. - Package managers speak their native protocol. npm, pip, Maven, NuGet, cargo, gem all use their standard client against CodeArtifact’s URL; nothing exotic on the developer’s machine beyond
aws codeartifact login.