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

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.

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

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

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
originPathdoubles your repository path. Don't use it. Put the full path in the index URL instead. cargo:tokenis notcargo: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-ownerand--regionare not optional when callingget-authorization-tokenin 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.jsonshould be committed. CDK caches hosted zone lookups there; omitting it means everycdk synthmakes 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.
