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:
|
1 2 3 4 5 |
{ "experimentalFeaturesEnabled": { "ociEnabled": true } } |
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:
|
1 |
bicep restore → modules.example.com → NGINX on Container Apps → myregistry.azurecr.io |
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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
# Create the registry az acr create -n myregistry -g rg-bicep-modules --sku Standard --location uksouth # Create a scope map for a specific module repository az acr scope-map create \ --name sm-storage-module \ --registry myregistry \ --repository storage-module content/read metadata/read \ --description "Read access to storage module" # Create a token bound to that scope map az acr token create \ --name customer-a-token \ --registry myregistry \ --scope-map sm-storage-module \ --output json |
The output includes a generated password. Save it. You cannot retrieve it again.
Revoking access later is a one-liner:
|
1 |
az acr token update --name customer-a-token --registry myregistry --status disabled |
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:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
@description('Name of the storage account') param name string @description('Azure region') param location string = resourceGroup().location @allowed(['Standard_LRS', 'Standard_GRS', 'Standard_ZRS']) param skuName string = 'Standard_LRS' resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = { name: name location: location sku: { name: skuName } kind: 'StorageV2' properties: { accessTier: 'Hot' supportsHttpsTrafficOnly: true minimumTlsVersion: 'TLS1_2' } } output id string = storageAccount.id output name string = storageAccount.name |
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.
|
1 2 |
az acr login -n myregistry bicep publish storage.bicep --target br:myregistry.azurecr.io/storage-module:v1.0 |
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:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
events {} http { server { listen 80; location / { proxy_pass https://myregistry.azurecr.io; proxy_set_header Host myregistry.azurecr.io; proxy_ssl_server_name on; proxy_ssl_verify off; } } } |
And a minimal Dockerfile:
|
1 2 |
FROM nginx:alpine COPY nginx.conf /etc/nginx/nginx.conf |
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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
# Build and push the proxy image az acr build -r myregistry -t acr-proxy/nginx:latest . # Create the Container Apps environment az containerapp env create -n cae-bicep-modules -g rg-bicep-modules --location uksouth # Create managed identity and grant AcrPull az identity create -n id-bicep-proxy -g rg-bicep-modules --location uksouth PRINCIPAL_ID=$(az identity show -n id-bicep-proxy -g rg-bicep-modules --query principalId -o tsv) IDENTITY_ID=$(az identity show -n id-bicep-proxy -g rg-bicep-modules --query id -o tsv) ACR_ID=$(az acr show -n myregistry --query id -o tsv) az role assignment create --assignee-object-id $PRINCIPAL_ID \ --role AcrPull --scope $ACR_ID --assignee-principal-type ServicePrincipal # Deploy the Container App az containerapp create \ -n ca-bicep-modules-proxy \ -g rg-bicep-modules \ --environment cae-bicep-modules \ --image myregistry.azurecr.io/acr-proxy/nginx:latest \ --registry-server myregistry.azurecr.io \ --registry-identity $IDENTITY_ID \ --user-assigned $IDENTITY_ID \ --ingress external --target-port 80 |
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:
|
1 2 |
az containerapp show -n ca-bicep-modules-proxy -g rg-bicep-modules \ --query properties.configuration.ingress.fqdn -o tsv |
Then bind the domain and let Container Apps issue a managed cert:
|
1 2 3 4 5 6 |
az containerapp hostname bind \ -n ca-bicep-modules-proxy \ -g rg-bicep-modules \ --hostname modules.example.com \ --environment cae-bicep-modules \ --validation-method CNAME |
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:
|
1 2 3 4 |
export BICEP_TRUSTED_REGISTRIES="modules.example.com" export DOCKER_REGISTRY="modules.example.com" export DOCKER_USERNAME="customer-a-token" export DOCKER_PASSWORD="<token-password>" |
The bicepparam file can reference the registry directly in the using statement, so no local bicep wrapper is needed:
|
1 2 3 4 |
using 'br:modules.example.com/storage-module:v1.0' param name = 'mystorageaccount' param location = 'uksouth' |
Running bicep build-params consumer.bicepparam restores and compiles the module. When it works, you will see:
|
1 |
WARNING: The following experimental Bicep features have been enabled: OCI registry support. |
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.
|
1 2 3 4 5 6 7 8 |
# Log in to their own subscription — nothing to do with your registry az login az account set --subscription "<their-subscription-id>" # Deploy — the bicepparam using 'br:...' reference is resolved at deploy time az deployment group create \ --resource-group my-rg \ --parameters consumer.bicepparam |
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.
0 Comments