---
title: "A Private Cargo Registry on AWS (And All the Ways It Will Trick You)"
date: "2026-04-25"
approved: true
publish_date: "2026-04-25"
excerpt: "CodeArtifact, CloudFront, OIDC, and a credential-provider gotcha that cost me an afternoon. A how-to for anyone who wants a private Cargo registry and has a healthy tolerance for yak-shaving."
tags: ["aws", "rust", "cargo", "codeartifact", "oidc", "github-actions", "cdk"]
author: "Martin Harris"
canonical: "https://blog.nakomis.com/2026-04-25-private-cargo-registry-codeartifact"
repos:
  - name: "codeartifact"
    url: "https://github.com/nakomis/codeartifact"
  - name: "pish"
    url: "https://github.com/nakomis/pish"
  - name: "rotary-dial"
    url: "https://github.com/nakomis/rotary-dial"
---

# A Private Cargo Registry on AWS (And All the Ways It Will Trick You)

![Robotic cranes sorting glowing crates at a night harbour, one crate open to reveal a trap of chains](./images/private-cargo-registry-codeartifact-header.png)

I have a Rust library — a rotary dial widget for my DIY thermostat — that I want to share between two different repos. The options were: publish to crates.io (public, permanent, and my thermostat code is not the world's most polished work), vendor the source (works until you forget to update it), or run a private registry.

![A dark harbour at night, shipping containers stacked high, a lone crate marked with a Rust logo](images/codeartifact-harbour.png)

AWS CodeArtifact supports Cargo via the sparse registry protocol. I already have AWS infrastructure. This seemed like the obvious choice. It was the right choice, but it was not a *fast* choice.

This is a how-to for anyone who wants to do the same, with all the traps clearly labelled.

Here's the overall picture before we get into the details:

![Architecture diagram: developer machine and GitHub Actions on the left, CloudFront proxying into a CodeArtifact domain in eu-west-2, IAM OIDC provider enabling keyless auth from GitHub Actions](images/codeartifact-architecture.svg)

The developer machine publishes via CloudFront; GitHub Actions pulls via the same proxy after exchanging an OIDC token for temporary AWS credentials. Neither path requires a long-lived secret anywhere.

## The CDK Setup

CodeArtifact organises things into *domains* (your namespace) and *repositories* (where packages live). You also need an upstream proxy repo to let CodeArtifact fetch public crates from crates.io on your behalf.

```typescript
const domain = new codeartifact.CfnDomain(this, 'Domain', {
  domainName: deployEnv === 'prod' ? 'nakomis' : 'nakomis-sandbox',
});

const cratesIoProxy = new codeartifact.CfnRepository(this, 'CratesIoProxy', {
  repositoryName: 'crates-io-proxy',
  domainName: domain.attrName,
  externalConnections: ['public:crates-io'],
});

const cargoRepo = new codeartifact.CfnRepository(this, 'CargoRepo', {
  repositoryName: 'cargo',
  domainName: domain.attrName,
  upstreams: [cratesIoProxy.attrName],
});
```

The `upstreams` line means that when a project pulls from `cargo`, CodeArtifact will transparently serve crates.io packages it hasn't seen before. Your private crates go in `cargo`; everything else comes through automatically.

I also added two IAM managed policies — one for reading (for CI), one for publishing (for my local machine and release tooling). They export their ARNs as CDK stack outputs so other stacks can reference them.

## The CloudFront Problem

Here is the first trap.

Cargo's sparse registry protocol constructs index URLs by appending paths like `/index/cr/at/crates-metadata.json` to your registry's base URL. CodeArtifact gives you an endpoint like:

```
https://nakomis-sandbox-975050268859.d.codeartifact.eu-west-2.amazonaws.com/cargo/cargo/
```

That URL works fine in a browser. The problem: CodeArtifact doesn't support custom domains. Your `config.toml` has to reference the `d.codeartifact.amazonaws.com` hostname, which is ugly, but more importantly it means every developer's machine and every CI runner needs to know your AWS account ID.

The solution is a CloudFront distribution sitting in front, so your registry URL is just `https://artifacts.sandbox.nakomis.com/cargo/cargo/`.

Setting this up requires the certificate to live in `us-east-1` (a familiar constraint if you've read [my previous post](/2026-02-26-the-certificate-that-had-to-live-in-america) — CloudFront always reads certificates from us-east-1, regardless of where your other infrastructure lives). That means a separate CDK stack for the certificate and `crossRegionReferences: true` on anything that touches it.

## The Double-Path Bug

Here is the second trap, and the more annoying one.

CloudFront lets you set an `originPath` — a prefix that gets prepended to every request path before it's forwarded to the origin. I set this to `/cargo/cargo` (the CodeArtifact repository path), so that a request to `https://artifacts.sandbox.nakomis.com/` would be forwarded as `/cargo/cargo/` to the origin.

Then I set the registry index URL in my `config.toml` to `sparse+https://artifacts.sandbox.nakomis.com/`.

And got 404s on every single crate.

The problem: when Cargo fetches a crate, it appends the repository path to your index URL. So a request to `artifacts.sandbox.nakomis.com/cargo/cargo/cr/at/crate-metadata.json` was arriving at CodeArtifact as `/cargo/cargo/cargo/cargo/cr/at/crate-metadata.json`. The path doubled.

The fix is to *not* use `originPath`, and instead include the full path in the index URL:

```toml
[registries.nakomis_codeartifact]
index = "sparse+https://artifacts.sandbox.nakomis.com/cargo/cargo/"
```

That way CloudFront forwards the path as-is, and CodeArtifact receives exactly what it expects. Simple enough, once you've spent an hour staring at CloudFront access logs wondering why a URL that works in `curl` is 404ing for Cargo.

## The Credential Provider Trap

![A stern bouncer in a black suit standing outside a server room door](images/codeartifact-bouncer.png)

CodeArtifact tokens expire after 12 hours. Cargo needs a way to get a fresh token automatically. The AWS documentation offers two approaches:

**`cargo:token`** — you provide the token value verbatim. This means exporting an environment variable before you run cargo:

```bash
export CARGO_REGISTRIES_NAKOMIS_CODEARTIFACT_TOKEN="Bearer $(aws codeartifact get-authorization-token ...)"
```

**`cargo:token-from-stdout`** — you provide a command, and Cargo calls that command to get the token when it needs one:

```toml
credential-provider = "cargo:token-from-stdout aws codeartifact get-authorization-token \
  --domain nakomis-sandbox --domain-owner 975050268859 \
  --region eu-west-2 --query authorizationToken --output text"
```

I initially used `cargo:token`. I kept getting 401s. I spent a while convinced this was a region mismatch or a policy issue or something interesting. It wasn't. The problem was that `cargo:token` expects the raw token value and sets the `Authorization: Bearer <token>` header itself — so if you'd already formatted your token as `Bearer eyJ...`, it would send `Authorization: Bearer Bearer eyJ...`.

Switch to `cargo:token-from-stdout` and it all works, because Cargo handles the auth header formatting internally when calling the AWS CLI. The extra flags (`--domain-owner` and `--region`) matter too: without them, the CLI might use your profile's default region, which may not be eu-west-2.

## The Hyphen/Underscore Registry Name Problem

This one is subtle enough that I'm calling it out separately.

Cargo registry names in `config.toml` can use hyphens or underscores — but they're not interchangeable. The name you give a registry in `[registries.my-registry]` is *different* from `[registries.my_registry]`. Cargo treats them as two separate registries.

This matters because if you're using `cargo:token` (not `token-from-stdout`), the token environment variable is derived from the registry name by uppercasing and replacing hyphens with underscores: `my-registry` becomes `MY_REGISTRY`. So `CARGO_REGISTRIES_MY_REGISTRY_TOKEN` would provide credentials for both `my-registry` and `my_registry` as far as the env var is concerned, but Cargo would still treat them as two distinct registries internally.

My advice: pick underscores, use them everywhere, and avoid the confusion entirely:

```toml
[registries.nakomis_codeartifact]
```

```bash
cargo publish -p rotary-dial --registry nakomis_codeartifact
```

## GitHub Actions OIDC: No Secrets Required

![A friendly robot shaking hands with a glowing cloud entity, circuit board patterns visible](images/codeartifact-robots-oidc.png)

The most satisfying part of this whole setup: the GitHub Actions workflow for `pish` (the project that depends on my private crate) doesn't contain any long-lived AWS credentials. No `AWS_ACCESS_KEY_ID` in GitHub secrets. No `AWS_SECRET_ACCESS_KEY`. Instead, it uses OIDC.

Here's how it works. When a GitHub Actions job runs, GitHub provides it with a short-lived OIDC token that proves which repo and workflow triggered it. AWS can be configured to trust GitHub's OIDC provider, and to exchange that GitHub token for temporary AWS credentials — scoped to a specific IAM role and valid only while the job runs.

The CDK for this is in a separate `GithubCiStack`:

```typescript
const role = new iam.Role(this, `${repoSlug}-role`, {
  roleName: `${repoSlug}-github-ci-${deployEnv}`,
  assumedBy: new iam.WebIdentityPrincipal(
    githubOidc.openIdConnectProviderArn,
    {
      StringEquals: {
        'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com',
      },
      StringLike: {
        'token.actions.githubusercontent.com:sub': `repo:${config.repo}:*`,
      },
    },
  ),
});
```

The `StringLike` condition scopes the role to a specific GitHub repo. Any workflow from `nakomis/pish` can assume it; nothing else can.

In the GitHub Actions YAML, one step handles the auth:

```yaml
- name: Configure AWS credentials (CodeArtifact)
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::975050268859:role/nakomis-pish-github-ci-sandbox
    aws-region: eu-west-2
```

After that step, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN` are set in the environment for the rest of the job. When Cargo subsequently calls `aws codeartifact get-authorization-token` via `token-from-stdout`, it picks those up automatically. No explicit token-fetching step needed.

The role has the CodeArtifact read managed policy attached. It can pull crates; it can't publish or do anything else.

## Publishing

Publishing goes through a local script rather than CI. The script sets the registry index and credential provider at publish time using `--config` flags, so the workspace `config.toml` doesn't need to contain any registry settings at all:

```bash
cargo publish -p rotary-dial --registry nakomis_codeartifact \
  --config "registries.nakomis_codeartifact.index='${INDEX}'" \
  --config "registries.nakomis_codeartifact.credential-provider='${CRED}'"
```

This is useful because it means the source repo has no AWS-specific configuration baked into it. The publish script takes an environment argument (`sandbox` or `prod`) and constructs the appropriate index URL and credential command.

One thing worth knowing: unlike crates.io, CodeArtifact lets you delete and republish the same version. This matters for dev workflows — I use a `0.1.0-dev` version number that I delete and republish on demand, crates.io-style immutability be damned.

## What I Learned

In rough order of how long each thing took to discover:

- **CloudFront `originPath` doubles your repository path.** Don't use it. Put the full path in the index URL instead.
- **`cargo:token` is not `cargo:token-from-stdout`.** Use the latter. AWS recommends it; there's a reason.
- **Hyphen vs underscore in registry names is significant.** Pick one and stick to it.
- **`--domain-owner` and `--region` are not optional** when calling `get-authorization-token` in a CI environment where the default region might not be what you expect.
- **OIDC for GitHub Actions is genuinely lovely** once the CDK is in place. No secrets, scoped to the repo, temporary by design.
- **`cdk.context.json` should be committed.** CDK caches hosted zone lookups there; omitting it means every `cdk synth` makes an AWS API call.

The whole thing — CodeArtifact, CloudFront proxy, OIDC roles, and working CI — took one long afternoon. Most of that time was the double-path bug and the credential provider confusion. If you're starting fresh with this post in hand, you should be able to do it in an hour or two.

{{donate}}

---

*Martin Harris connects real hardware to cloud infrastructure. If you've ever published a private crate and spent the afternoon wondering why `Authorization: Bearer Bearer eyJ...` isn't valid, you're in exactly the right place.*
