Martin Harris

"Ceci n'est pas une API Gateway" — René Magritte (paraphrased)

Making cloud abstractions tangible through physical hardware.

Chat with me →

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

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

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

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

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.

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 — 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:

[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

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:

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:

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:

[registries.nakomis_codeartifact]
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

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:

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:

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

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.


If you find this blog useful, please consider a donation.


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.