Skip to main content

Secure GitOps with signed OCI artifacts in AKS with the Flux extension

·13 mins·
Azure AKS Flux OCI Cosign GitOps
Table of Contents

The great thing about Flux is its OCI repositories feature, which lets you store and deliver sources such as Kubernetes manifests, Kustomize overlays and Helm charts as OCI artifacts in a registry. That way, GitOps manifests can be versioned, signed and verified just like container images. Flux’s OCI repository feature and Azure KeyVault integration makes it possible to bring supply chain security to AKS GitOps deployments.

Storing Kubernetes manifests as OCI artifacts in a container registry offers significant advantages: deployments become more secure through cryptographic signing, more reproducible through immutable digests, and easier to govern through unified access control. The Flux extension for AKS supports OCI artifacts as a source for GitOps manifests and can verify signatures using Cosign.

While Flux supports both Cosign and Notation for OCI repository verification , and Microsoft has even contributed to Notation support, only Cosign is supported in the Flux extension for AKS.

Another big problem is that it isn’t well documented. The Microsoft documentation doesn’t provide good instructions on how to set this up. This post explains the necessary steps for configuring signed OCI Artifacts with the Flux extension in AKS, and by the end of this post you’ll have:

  • GitOps artifacts: GitOps artifacts converted into signed OCI artifacts.
  • Signed artifacts: OCI artifacts cryptographically signed with Cosign.
  • Key Vault integration: Signing keys managed securely in Azure Key Vault (keys never leave the vault).
  • Workload Identity authentication: No stored credentials, Flux uses federated identity to pull artifacts.
  • Environment promotion: Same signed artifact promoted through environments (dev → tst → prd) using tags.
  • Verification on deploy: Flux validates signatures before applying any manifests to the cluster.

Info
#

Why OCI Artifacts for GitOps

Traditional GitOps uses Git repositories as the source of truth. While this works well, it introduces challenges in enterprise environments:

  • Clusters need Git credentials (tokens or SSH keys), increasing the blast radius if credentials leak.
  • Direct Git access makes automated signature verification more difficult.
  • Network policies must allow outbound Git access, which may conflict with security requirements.
  • Git provider outages, rate limits, or connectivity issues can break reconciliations.
  • Tag or branch changes can cause unintended drift.
  • Tracing deployments back to specific builds requires additional tooling.

OCI Artifacts

OCI artifacts address these challenges by treating the Kubernetes manifests like container images:

Capability Benefit
Digest based references Immutable references ensure exact reproducibility. What you build is what you deploy
Cryptographic signing Verify provenance and integrity before deployment, reject tampered artifacts
Registry native storage Unified access control, caching, and geographic replication alongside container images
Build pipeline integration Publish manifests in the same workflow as images, creating end-to-end traceability from commit to deployment

Solution
#

The solution consists of the following components:

solution

GitOps Repository: The GitHub repository containing GitOps manifests, scripts, and OCI artifact workflows. The repository I used to set up this solution is available here: AksFluxOciArtifacts .

Multi-stage Pipelines: Orchestrates the complete OCI artifact lifecycle:

  • Build Stage: Creates OCI artifacts from GitOps manifests
  • Push & Sign Stages: Deploys artifacts to ACR with cryptographic signatures
  • Promotion Stages: Progressive deployment across environments (dev → tst → prd)

Key Vault: Contains the Cosign signing key. The Flux source controller uses the corresponding public key to validate artifacts.

Azure Container Registry (ACR): Stores OCI artifacts with their Cosign signatures. All clusters use the same ACR, so the signature remains valid across all environment tags.

AKS Cluster: Has the Flux extension configured with a user-assigned managed identity. The Flux extension is federated with the source controller to retrieve OCI artifacts from ACR.

Implementation
#

The implementation consists of three main steps:

  1. Deploy the necessary Azure resources
  2. Build, push, and sign the artifacts (GitHub Actions)
  3. Configure Flux in AKS (Bicep)

Deploy Azure resources
#

We’ll start by deploying the necessary Azure resources:

Click here to see the code
$resourceGroup = "teknologi-aks-rg"
$aksName = "teknologi-aks"
$acrName = "teknologiaks"
$acrSku = "Basic"
$keyVaultName = "teknologi-aks-kv"
$fluxIdentityName = "teknologi-aks-flux"
$location = "westeurope"

# Create resource group
az group create --name "$resourceGroup" --location "$location"

# Create ACR
az acr create `
  --resource-group "$resourceGroup" `
  --name "$acrName" `
  --sku "$acrSku" `
  --admin-enabled false

# Create Key Vault
az keyvault create `
  --name "$keyVaultName" `
  --resource-group "$resourceGroup" `
  --location "$location" `
  --sku standard

# Create AKS cluster
az aks create `
  --resource-group "$resourceGroup" `
  --name "$aksName" `
  --enable-workload-identity `
  --enable-oidc-issuer `
  --attach-acr "$acrName"

# Create Flux managed identity
az identity create `
  --resource-group $resourceGroup `
  --name $fluxIdentityName `
  -o json

# Assign AcrPull role to the Flux identity
az role assignment create `
   --role "AcrPull" `
   --scope $(az acr show --name $acrName --resource-group $resourceGroup --query "id" -o tsv) `
   --assignee-object-id $(az identity show --resource-group $resourceGroup --name $fluxIdentityName --query "principalId" -o tsv)

# Create federation for Flux identity and Flux source controller
az identity federated-credential create `
  --name "flux-source-controller" `
  --identity-name $fluxIdentityName `
  --resource-group $resourceGroup `
  --issuer $(az aks show --name $aksName --resource-group $resourceGroup --query "oidcIssuerProfile.issuerUrl" --output tsv) `
  --subject "system:serviceaccount:flux-system:source-controller" `
  --audiences "api://AzureADTokenExchange"

The script for deploying the Azure resources can also be found here: deploy-resources.ps1 .

The following resources are deployed:

  • Azure Container Registry: Private container image registry for storing your application images and OCI artifacts. Instead of using admin credentials, the script sets up role-based access control (RBAC) with managed identities, providing a more secure authentication mechanism.

  • Key Vault: Contains the Cosign key used to sign and validate OCI artifacts.

  • AKS Cluster: The AKS cluster configured with:

    • Workload Identity: Enables Kubernetes workloads to securely access Azure resources without storing credentials.
    • OIDC Issuer: Provides OpenID Connect authentication for federated identity scenarios.
    • ACR Integration: Automatically configures the cluster to pull images from the attached container registry.
  • Managed Identity for Flux: A dedicated managed identity for Flux operations.

    • The script assigns the AcrPull role to the Flux managed identity, granting it permission to pull container images and OCI artifacts from ACR.

    • It creates a federated credential that establishes a trust relationship between the Flux source controller (running as a service account in the flux-system namespace), the Azure managed identity, and the AKS cluster’s OIDC issuer.

      This federation allows the Flux source controller to assume the Azure managed identity without any stored credentials, using the AKS cluster’s built-in OIDC token exchange mechanism. This way, Flux can automatically pull OCI artifacts from ACR, and the cluster state continuously reconciles with the desired state defined in the OCI repository.

Now that the Azure resources are deployed, we can configure the artifacts in ACR with the OCI artifact pipelines.

Build, Push, and Sign Artifacts
#

The GitHub Action workflows are available here: workflows .

To deploy and configure Azure resources with GitHub Action workflows, I’m using federated credentials with a user-assigned managed identity that is federated with the GitHub repository. For setup instructions, see the README .

The GitHub Action identity needs the following RBAC roles:

  • Owner/Contributor access rights on the resource group.
  • Key Vault Administrator access rights on the Key Vault.

Configuring the GitOps artifacts in ACR involves three steps automated through GitHub Actions:

artifacts-pipeline-workflow

Build the artifact
#

This workflow automates the process of converting Kubernetes manifest directories into OCI artifacts using the Flux CLI (flux build artifact). It packages the content from the directories into compressed OCI artifacts (.tgz files) and stores them as workflow artifacts.

In the repository, there’s a folder named clusters that defines the clusters managed by the GitOps manifests. This folder will be stored as an artifact in a shared ACR that multiple clusters can access. As an example, there’s currently one cluster (clusters/teknologi/dev) used to test the solution. The Flux configuration for each cluster has a Kustomization path to a particular path in the OCI artifact (in this case /teknologi/dev), so it only syncs GitOps manifests specific to that cluster.

An example for the script can be found here: build-oci-artifacts.ps1

The workflow artifact is now available for other stages/jobs in the workflow.

Push and sign the artifact
#

This workflow handles the secure pushing and signing of OCI artifacts to ACR. It’s the second stage in the GitOps pipeline that takes built artifacts and makes them available for deployment.

This workflow requires two tools:

  • ORAS: For pushing OCI artifacts to container registries
  • Cosign: For signing artifacts.

Important: Cosign version v2.6.1 is used here instead of the latest Cosign version v3. Cosign v3 is not yet supported in current Flux extension versions. In my case, I was using Flux extension version 1.18.4, which corresponds to Flux v2.6.4 . Support for Cosign v3 will be added in a future Flux release.

This workflow consists of the following steps:

  1. Configure the signing key

This step checks if a signing key exists in Key Vault, and if not, creates one (an EC key type with P-256 curve) with the following command:

$key = az keyvault key create `
  --vault-name $vaultName `
  --name $name `
  --kty EC `
  --curve P-256 `
  --protection software `
  --ops sign verify `
  --tags "purpose=cosign" "created=$(Get-Date -Format 'yyyy-MM-dd')" `
  --output json 2>&1

After the key is created, it verifies that Cosign can access the public key in Key Vault:

$keyId = "azurekms://$KeyVaultName.vault.azure.net/$keyName"
$testOutput = cosign public-key --key "$keyId" 2>&1

Cosign uses the following format to reference keys in Key Vault:

cosign sign --key "azurekms://$keyVaultName.vault.azure.net/$keyName" testimage:latest
cosign verify --key "azurekms://$keyVaultName.vault.azure.net/$keyName" testimage:latest

An example for the script can be found here: generate-signing-key.ps1

  1. Push and sign OCI artifacts

The next step transforms the locally built artifacts into signed, registry-stored artifacts. It retrieves the .tgz files created by the build process and performs a registry push with ORAS:

$fullRef = "$acrName.azurecr.io/$($repository):$($buildNumber)"

$pushOutput = oras push `
  "$fullRef" `
  "$($relativePath):application/gzip" `
  --annotation "org.opencontainers.image.title=$artifactName" `
  --annotation "org.opencontainers.image.revision=$fullSha" `
  --annotation "org.opencontainers.image.source=$sourceUrl" `
  --annotation "org.opencontainers.image.created=$(Get-Date -Format o)"

The artifacts are stored with the given repository name and build number (for example teknologiacr.azurecr.io/clusters:21), with Git metadata (SHA, branch, source URL) for traceability and creation time for audit trails.

It then retrieves the digest of the pushed artifact and uses Cosign to create and verify the cryptographic signature:

$manifestOutput = oras manifest fetch "$fullRef" --descriptor
$manifestJson = $manifestOutput | ConvertFrom-Json
$digest = $manifestJson.digest
$signRef = "$acrName.azurecr.io/$repository@$digest"

$keyIdentifier = "azurekms://$keyVaultName.vault.azure.net/$signingKeyName"
$signOutput = cosign sign --key "$keyIdentifier" "$signRef" --yes 2>&1

$verifyOutput = cosign verify --key "$keyIdentifier" "$signRef" 2>&1

This process ensures:

  • The same content always produces the same digest
  • What is signed is what is deployed
  • Reproducible builds that are independent of registry tags
  • The signing key never leaves the secure vault
  • The immutable content hash is signed, not the mutable tag
  • The sign/verify cycle works correctly

The last step saves a summary of the processed artifacts as a pipeline output:

$processed += @{
  FileName = $tgzFile.Name
  ArtifactName = $artifactName
  Digest = $digest
  Reference = $signRef
  BuildNumber = $buildNumber
  Repository = $repository
}

$summaryJson = $processed | ConvertTo-Json -Depth 10 -Compress
"ProcessedArtifacts=$summaryJson" | Add-Content -Path $env:GITHUB_OUTPUT

This summary stores artifact metadata and enables tracking of what was processed and where it was stored. It can be used by subsequent jobs in the pipeline (like the promote workflows).

An example for the script can be found here: push-sign-oci-artifacts.ps1 .

Promote artifact
#

The final stage for the OCI artifacts workflow is promoting signed OCI artifacts from one environment to another by applying environment-specific tags. This enables controlled deployment across different environments where all environments reference the same immutable artifact and the signature remains valid across all tags.

This process is fairly simple. It uses the summary artifact results.json created in the build-sign stage, which contains the artifact references with digests and metadata. For each result, it fetches the artifact from ACR with ORAS:

foreach ($artifact in $resultSummary) {
  $repository = $artifact.Repository
  $digest = $artifact.Digest
  $fullReference = $artifact.Reference

  oras manifest fetch "$fullReference" --descriptor 2>&1 # Verify artifact

  az acr import `
    --name $registry `
    --source "$fullReference" `
    --image "$($repo):$environment" `
    --force 2>&1 | Out-Null
}

The az acr import command ensures all OCI annotations and labels are maintained, and the Cosign signatures remain attached to the content. Environment tags point to specific builds without exposing build numbers.

Promotion is based on the main orchestrating workflow aks-oci-artifacts.yaml. The workflow follows a fan-out promotion pattern after the build and sign stages:

jobs:
  build-oci-artifacts:
    uses: ./.github/workflows/aks-oci-build.yaml

  push-sign-oci-artifacts:
    needs: build-oci-artifacts
    uses: ./.github/workflows/aks-oci-push.yaml
    with:
      keyVaultName: "ashwin-aks-kv"
      signingKeyName: oci-artifact-signing-key
      acrName: ashwinaks
    secrets: inherit

  promote-dev:
    needs: push-sign-oci-artifacts
    uses: ./.github/workflows/aks-oci-promote.yaml
    with:
      environment: dev
      acrName: ashwinaks
    secrets: inherit
  
  promote-tst:
    needs: push-sign-oci-artifacts
    uses: ./.github/workflows/aks-oci-promote.yaml
    with:
      environment: tst
      acrName: ashwinaks
    secrets: inherit
  
  promote-prd:
    needs: push-sign-oci-artifacts
    uses: ./.github/workflows/aks-oci-promote.yaml
    with:
      environment: prd
      acrName: ashwinaks
    secrets: inherit

Each environment promote stage applies environment specific-tags to the same signed artifacts.

Running the pipeline creates a signed artifact with three tags for each environment:

GitHub Action Result

This is how the images are stored in ACR after running the workflow:

ACR Artifacts

To implement branch-based promotion gates (main branch required for tst/prd), environment protection rules can be set up in the GitHub repository:

  • Navigate to GitHub repository → Settings → Environments
  • For tst and prd, add Protection Rules:
    • Required reviewers: Add team members who must approve
    • Deployment branches: Restrict which branches can deploy

This gives you:

Environment Protection

  • Sequential execution: dev → tst → prd
  • Manual approvals: Required for tst and prd
  • Deployment history per environment
  • Automatic stage dependency management

Now that the artifact is signed and ready to be consumed by an AKS cluster, it’s time to configure the Flux extension to use the OCI artifact.

Configure Flux in AKS
#

Configuring Flux in the AKS cluster will be done with Bicep . The Bicep template automates the complete setup of GitOps capabilities in the AKS cluster, installing and configuring Flux for OCI artifacts.

Configure Flux extension
#

This step deploys the Flux GitOps operator on the cluster. The template is available here: fluxExtension.bicep .

It installs the Flux v2 controllers (source, kustomize, helm, notification controllers) and configures workload identity that the source controller uses to retrieve OCI artifacts from ACR with the previously created managed identity (teknologi-aks-flux).

The following settings are important to enable workload federation with Flux:

'workloadIdentity.enable': 'true'
'workloadIdentity.azureClientId': fluxManagedIdentity.properties.clientId
'source-controller.feature-gates.ObjectLevelWorkloadIdentity': 'true'

Configure Flux configuration
#

The next step deploys a Flux configuration (defining what and how Flux should synchronize). The template is available here: fluxConfiguration.bicep .

The OCI repository configuration looks like this:

sourceKind: 'OCIRepository'
ociRepository: {
  url: 'oci://ashwinaks.azurecr.io/manifests/clusters'
  repositoryRef: {
    tag: 'dev'                              // Environment-specific tag (dev, tst, prd)
  }
  syncIntervalInSeconds: 120                       
  useWorkloadIdentity: true
  verify: {
    provider: 'cosign'                      // 'cosign' for signature verification
    verificationConfig: {
      'cosign.pub': cosignPublicKey         // Base64-encoded public key
    }
  }
}

The Microsoft documentation provides little information on how to set this up, so this took considerable time and trial and error to configure.

Important: If you see this error when configuring a Flux config:

properties.sourceKind: Invalid property. Setting value must be one of the specified values '[GitRepository, Bucket, AzureBlob]'. (Code: InvalidInputParameter)

Make sure to use API version 2025-04-01 or later for Microsoft.KubernetesConfiguration/fluxConfigurations.

The most important part is how to pass the signing key’s public key to the OCI repository configuration. This must be passed to the Bicep template as follows:

# Get Cosign public key from Key Vault
az keyvault key download `
  --vault-name $keyVaultName `
  --name $signingKeyName `
  --file cosign-public-key.pem `
  --encoding PEM

# Read the public key content and convert to base64
$publicKeyContent = Get-Content -Path "cosign-public-key.pem" -Raw
$cosignPublicKey = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($publicKeyContent))

The Kustomization configuration looks like this:

kustomizations: {
  'config': {
    path: './teknologi/dev' 
    syncIntervalInSeconds: 120           
    prune: true
    dependsOn: []
  }
}

The Kustomization path references the ./teknologi/dev path in the OCI repository.

After deploying the Bicep templates, the cluster will have:

  • The Flux extension configured with all Flux components
  • A Flux configuration named cluster set up with OCIRepository as source kind, and one Kustomization named config that reconciles the ./teknologi/dev path in the OCI repository
  • An OCIRepository CRD named cluster
  • A Kustomization CRD named cluster-config that reconciles the GitOps manifests

Verify
#

Now let’s check if it works in the cluster:

The Flux configuration is created:

flux config

The Flux configuration references the ./teknologi/dev path in the OCI repository:

flux config kustomizations

The only thing missing in the Azure portal is the ability to see the OCI repository source settings for the Flux configuration:

flux config source

The OCI repository CRD is available in the cluster and has synced the signed OCI artifact:

OCI CRD

Conclusion
#

Setting up signed OCI artifacts with the Flux extension in AKS required some trial and error, especially when there is no helpful documentation from Microsoft, but the result is worth it.

The complete code for this solution is available in my GitHub repository: AksFluxOciArtifacts .

Have questions or run into issues? Feel free to reach out on LinkedIn!