URL has been copied successfully!
URL has been copied successfully!
URL has been copied successfully!
URL has been copied successfully!
URL has been copied successfully!
Share:
Twitter
LinkedIn
Facebook
Reddit
Follow by Email
Copy link
Threads
Bluesky
Reading Time: 6 minutes

Bicep 0.45.6 added ociEnabled, an experimental feature that routes module pulls through ORAS using Docker-style credentials instead of the Azure SDK. I have been waiting for something like this for a long time. If you work at an MSP or build shared platform tooling, you will know why. You have infrastructure patterns packaged as Bicep modules. Customers should be able to consume them when they are under contract and lose access when they are not, with no service principal hand-outs, no guest accounts, and no visibility into your registry beyond what they paid for. ACR repository-scoped tokens are exactly the right credential for that. ociEnabled looked like the missing piece.

It is not quite that simple. There is a routing rule in the Bicep CLI that catches most people out, and working around it takes more infrastructure than it should. This is what I built.

Why *.azurecr.io will not work directly

ociEnabled was designed for non-ACR registries such as GitHub Container Registry (GHCR), Docker Hub, or a self-hosted registry like Harbor. It routes module pulls through ORAS (OCI Registry As Storage) and authenticates with standard Docker-style credentials. That is exactly how ACR repository-scoped tokens work, which is why this looked so promising.

The feature is enabled per-project in bicepconfig.json:

The Bicep team made a deliberate decision to keep *.azurecr.io hostnames on the Azure SDK path regardless of ociEnabled. If your module reference points at myregistry.azurecr.io, Bicep uses Entra ID auth. Full stop. The ORAS path only kicks in for non-*.azurecr.io hostnames.

That means token-based auth on a plain ACR URL will never work with the current design, even with ociEnabled: true in your bicepconfig.

The workaround: a custom domain with an NGINX proxy

Since the problem is the hostname, the fix is to not use the ACR hostname directly. Put a custom domain in front of ACR using an NGINX reverse proxy, and Bicep routes through ORAS and uses your Docker credentials.

It is a workaround rather than a proper solution. ACR currently supports custom domains natively in a private preview. Until that reaches public preview or GA, or until the Bicep team revisits the routing rule, this proxy approach is the path forward.

The flow looks like this:

ORAS hits your custom domain, NGINX forwards the request to ACR with the correct Host header, ACR returns its WWW-Authenticate Bearer challenge pointing at its own token endpoint, ORAS follows that, authenticates with the ACR token credentials, and pulls the module.

A plain CNAME from your domain to *.azurecr.io will not work because of TLS certificate mismatch. The ACR cert covers *.azurecr.io, not your domain. You need to terminate TLS at something you control, which is where Azure Container Apps with a managed cert comes in.

Setting up ACR with scope-mapped tokens

You need an ACR (Standard SKU or higher to get tokens), a scope map, and a token. The scope map controls exactly which repositories the token can access.

The output includes a generated password. Save it. You cannot retrieve it again.

Revoking access later is a one-liner:

No role assignment changes, no tenant coordination. The token stops working immediately.

Creating and publishing a module

For this example I am using a simple storage account module. Create storage.bicep:

Publishing goes directly to ACR using bicep publish. You need Azure credentials for this step. This is the publisher side and you are in your own tenancy.

Once published, hand the token credentials to your consumer. They never need Azure login to pull it.

Building the NGINX proxy on Container Apps

The proxy is a custom NGINX image that forwards all traffic to your ACR with the correct host header. Start with nginx.conf:

And a minimal Dockerfile:

Build the image with ACR Tasks (no local Docker required), then deploy it as a Container App. The Container App needs a managed identity with AcrPull on the registry so it can pull the proxy image at startup.

Add a DNS CNAME from your custom domain to the full Container App FQDN. Use the app-level FQDN, not the environment domain. Get it with:

Then bind the domain and let Container Apps issue a managed cert:

Certificate provisioning takes a few minutes. Once done, https://modules.example.com/v2/ should return a 401 with a Bearer challenge. That is ACR’s auth handshake and means the proxy is working correctly.

Consumer setup

Consumers need Bicep 0.45.6 or later. The BICEP_TRUSTED_REGISTRIES environment variable that tells Bicep to allow the custom domain was not present in earlier versions, and without it the restore will fail. Check with bicep --version and upgrade with az bicep upgrade if needed.

The bicepconfig.json shown earlier (with ociEnabled: true) goes in the same directory as their Bicep files. Then four environment variables before running any bicep command. In a pipeline these would be secrets:

The bicepparam file can reference the registry directly in the using statement, so no local bicep wrapper is needed:

Running bicep build-params consumer.bicepparam restores and compiles the module. When it works, you will see:

That warning confirms ORAS was used, not the Azure SDK. No Azure login, no Entra ID, just the token.

Deploying

Once the module restores cleanly, actual deployment uses the consumer’s own Azure identity into their own subscription. The token and the deployment credentials are completely separate concerns.

The token env vars need to be set for the duration of the az deployment command too, since the CLI will restore the module into the local cache if it is not already there. Once cached, subsequent deployments work without them until the cache is cleared.

Conclusion

The access control model here is genuinely good. Scope-mapped tokens give you per-repository, per-consumer access that revokes in a single command with no role changes and no tenant coordination. For an MSP that is a clean way to license infrastructure. One token per product per customer, scoped to exactly what they paid for, gone the moment the contract ends.

The frustrating part is that none of the proxy infrastructure should be necessary.

It all exists because the Bicep team chose to keep `*.azurecr.io` on the Azure SDK path regardless of `ociEnabled`. I get why they did it. ACR is an Azure product and Entra ID auth is the sensible default. But I would love to see `ociEnabled` extended to cover `*.azurecr.io` too, with a credential-first fallback. If Docker credentials are set in the environment, use them. If not, fall through to Entra ID as it does today. That one change would remove every line of the proxy setup and make this pattern as clean as it deserves to be.

Until then, the Container App and DNS record are the tax you pay. Cheap to run, but an extra thing to maintain and an extra failure point to explain. When ACR adds native custom domain support or the Bicep routing rule changes, the proxy disappears. Keep an eye on both.

If you need this today, build it. I tested it fully logged out of Azure and it works. The pattern is sound, the access control story is right, and it is one routing decision away from being genuinely elegant.

Share:
Twitter
LinkedIn
Facebook
Reddit
Follow by Email
Copy link
Threads
Bluesky
Categories: AzureBicep

Pixel Robots.

I’m Richard Hooper aka Pixel Robots. I started this blog in 2016 for a couple reasons. The first reason was basically just a place for me to store my step by step guides, troubleshooting guides and just plain ideas about being a sysadmin. The second reason was to share what I have learned and found out with other people like me. Hopefully, you can find something useful on the site.

0 Comments

Leave a Reply

Avatar placeholder

Your email address will not be published. Required fields are marked *