Skip to content

Blob Storage

This guide explains how to enable and integrate Azure Blob Storage with API services using the Forge platform, covering both Terraform infrastructure setup and Aspire local development.

đŸŽ¯ Overview

Azure Blob Storage provides scalable, secure object storage for unstructured data. The Forge platform provides:

  • Terraform Module: Automated Azure Blob Storage Account provisioning
  • Aspire Integration: Local development and testing with storage emulator
  • RBAC Authentication: Secure, token-based access via the app's Entra identity (no keys or SAS tokens)
  • Feature Flags: Flexible enable/disable configuration

🔧 Infrastructure Setup (Terraform)

Enable Blob Storage Feature Flag

Blob storage is a feature flag on the saif-apiservice module, so it is configured in the module call that the templates generate for you — infra/api/app.generated.tf.

â„šī¸ Projects scaffolded before Forge 3.x keep this module call in infra/app/app.generated.tf — see the Forge v2 → v3 migration guide.

Add a feature_flags block to the existing module "saif-appservices" call:

feature_flags = {
  enable_blob_storage = true
  blob_storage_settings = {
    containers           = ["blobcontainer"]
    contributor_group_id = "group-object-id-guid"
  }
}

âš ī¸ enable_blob_storage and blob_storage_settings are members of the feature_flags object — they are not top-level module arguments.

Configuration Options:

Property Type Description Example
enable_blob_storage bool Enable/disable blob storage deployment true
containers list(string) List of blob container names to create ["uploads", "archives"]
contributor_group_id string Entra ID group object ID granted contributor access to blob data ("" grants the app's managed identity only) "12345678-1234-1234-1234-123456789abc"

â„šī¸ Network isolation is always on and not configurable here (#1003) — the app reaches blobs over a private endpoint, and the public data-plane endpoint denies everything except SAIF's corporate/colocation IP ranges and Microsoft Defender for Storage's scanner. This module is intentionally opinionated; see Network Isolation below. If you need the pre-#1003 shape (public endpoint, no PE, RBAC-only) or otherwise need to control private_endpoint_enabled/network_rules_enabled, use the saif-resources/modules/storage module directly instead of this API service's blob_storage_settings.

âš ī¸ Hardcode the object ID — don't resolve it with a data source (e.g. data.azuread_group.example.object_id). Terraform plans that look up the group at plan time fail the Organization Policy Check. Look up your team's Entra ID group object ID once (Entra admin center or az ad group show --group "<display name>" --query id -o tsv) and paste the literal GUID into blob_storage_settings.

Example Terraform Configuration

The generated module call already supplies owner, project_id, environment, and tags from vars.yml — you only add the feature_flags block:

module "saif-appservices" {
  source  = "app.terraform.io/SAIFCorp/saif-apiservice/azure"
  version = ">= 3.0.0, < 4.0.0"

  # ... existing generated module arguments ...

  feature_flags = {
    enable_blob_storage = true
    blob_storage_settings = {
      containers = [
        "documents",
        "uploads",
        "archives"
      ]
      # Hardcoded object ID for your team's Entra ID security group — this can be a
      # cloud-native Entra ID group or an on-prem AD group synced via Entra Connect/Cloud
      # Sync, both resolve the same way. Do not replace this with a `data` source lookup;
      # the Organization Policy Check disallows it.
      contributor_group_id = "12345678-1234-1234-1234-123456789abc"
    }
  }
}

Generated Resources

When enable_blob_storage = true, the following resources are automatically created:

  • đŸ›ī¸ Azure Storage Account — named {projectid}{environment_short_name}{tenant_short_name} with dashes removed and truncated to 24 characters (e.g. myapitestcorp, myapiprodcorp)
  • đŸ“Ļ Blob Containers (with names from containers list, all private access)
  • 🔐 RBAC Role Assignments:
  • Storage Blob Data Contributor: assigned to the application's Entra app registration service principal (the runtime data-plane identity)
  • Storage Blob Data Contributor: assigned to the group in contributor_group_id
  • âš™ī¸ App Setting: ConnectionStrings__blobstorage is added to the web app with the primary blob endpoint of the new storage account
  • 🔌 Private Endpoint — blob sub-resource in the services subnet, registered in the privatelink.blob.core.windows.net DNS zone

đŸ›Ąī¸ Network Isolation

A deny-by-default firewall is always applied to the storage account's public data-plane endpoint (default_action = Deny, bypass = ["AzureServices"]), on top of the private endpoint — neither is configurable from blob_storage_settings. Traffic over the private endpoint always bypasses the firewall, so your app's access is unaffected either way.

SAIF's corporate and colocation IP ranges are always allowed through the firewall, matching the Cosmos DB module's saif_ip_ranges pattern, so browsing from the office or a jump box on the corporate network keeps working. Microsoft Defender for Storage's malware-scanning service is also granted access via a private_link_access resource instance rule — the generic AzureServices bypass does not cover it.

â„šī¸ bypass = ["AzureServices"] also lets other Azure trusted services reach the account, not just the corporate ranges and Defender's scanner — see Microsoft Learn's list of trusted services for the full exception scope.

VPN Access Does Not Work

Connecting over VPN from home does not resolve to a SAIF corporate IP address, so the Azure Portal Storage Browser and az storage CLI will get a 403 from the firewall. You must access the storage account's data plane from a virtual machine inside the corporate network or be on the corporate network itself — the same constraint as Cosmos DB's Data Explorer (see Accessing Cosmos DB Data).

If your team needs off-network data-plane access beyond what the built-in corporate IP ranges cover (e.g. a VPN egress range not already listed), or needs to opt out of network isolation entirely (the pre-#1003 shape), that requires using the saif-resources/modules/storage module directly — none of this is configurable from blob_storage_settings.

đŸ–Ĩī¸ Local Development (Aspire)

Aspire Storage Emulator Setup

For local development, use the Azure Storage Emulator integrated with Aspire:

AppHost Configuration

// AppHost.cs in YourApp.AppHost

var builder = DistributedApplication.CreateBuilder(args);

// Add Azure Storage with emulator
var blobs = builder
    .AddAzureStorage("storage")
    .RunAsEmulator()
    .AddBlobs("blobstorage");

// Reference storage in your backend service
var backend = builder
    .AddApi()
    .WithReference(blobs);

// Add web frontend
var frontend = builder
    .AddWebFrontEnd(backend);

builder.Build().Run();

Configuration Options:

  • .RunAsEmulator() - Uses local storage emulator (no Azure account required)
  • .AddBlobs("blobstorage") - Adds blob storage resource with specified name (used to reference in services)
  • .WithReference() - Injects storage connection details into service

Using Blob Storage in Your Service

Service Registration

In your Program.cs, register the Azure Blob Service client:

// Program.cs
builder.AddAzureBlobServiceClient("blobstorage");

âš ī¸ The connection name passed to AddAzureBlobServiceClient must match the name given to AddBlobs in the AppHost, and the ConnectionStrings__{name} app setting Forge creates in Azure — otherwise the client cannot resolve a connection string at startup.

â„šī¸ AddAzureBlobServiceClient automatically uses DefaultAzureCredential when deployed to Azure and connection strings for local development. In Azure, the platform pins the credential chain to the app registration service principal (AZURE_TOKEN_CREDENTIALS=environmentcredential), which holds the blob RBAC role.

Minimal API Examples

using Azure.Storage.Blobs;

// Upload endpoint
app.MapPost("/api/upload", async (IFormFile file, BlobServiceClient blobServiceClient) =>
{
    if (file is null || file.Length == 0)
    {
        return Results.BadRequest(new { Error = "No file provided" });
    }

    var containerClient = blobServiceClient.GetBlobContainerClient("uploads");
    await containerClient.CreateIfNotExistsAsync();

    var blobName = $"{Guid.NewGuid()}-{file.FileName}";
    var blobClient = containerClient.GetBlobClient(blobName);

    await using var stream = file.OpenReadStream();
    await blobClient.UploadAsync(stream, overwrite: true);

    return Results.Ok(new
    {
        FileName = file.FileName,
        BlobName = blobName,
        Size = file.Length,
        UploadedAt = DateTime.UtcNow
    });
});

// List files endpoint
app.MapGet("/api/files", async (BlobServiceClient blobServiceClient) =>
{
    var containerClient = blobServiceClient.GetBlobContainerClient("uploads");

    if (!await containerClient.ExistsAsync())
    {
        return Results.Ok(Array.Empty<object>());
    }

    var blobs = new List<object>();
    await foreach (var blob in containerClient.GetBlobsAsync())
    {
        blobs.Add(new
        {
            Name = blob.Name,
            Size = blob.Properties.ContentLength,
            CreatedOn = blob.Properties.CreatedOn
        });
    }

    return Results.Ok(blobs);
});

📚 Example: Aspire Blob Storage Project

The Forge platform includes a complete example at foundry/dotnet/aspire-blobstorage/:

Project Structure

aspire-blobstorage/
├── infra/
│   ├── api/                          # API service Terraform (app.generated.tf lives here)
│   ├── auth/                         # Entra ID (corp) and Okta (ext) auth configuration
│   └── web/                          # Front-end Terraform
├── src/
│   ├── blobstorage.AppHost/          # Aspire orchestration
│   │   ├── AppHost.cs                # Service configuration
│   │   └── Extensions.cs             # Aspire extensions
│   ├── blobstorage/                  # API backend
│   │   └── Program.cs                # Blob upload/list endpoints
│   ├── blobstorage.Frontend/         # React frontend
│   │   └── [UI components]
│   └── blobstorage.TypeSpec/         # OpenAPI definitions
└── README.md

Running the Example

cd foundry/dotnet/aspire-blobstorage

# Start with Aspire orchestration
dotnet run --project src/blobstorage.AppHost

The Aspire dashboard URL is printed in the console at startup (the port comes from src/blobstorage.AppHost/Properties/launchSettings.json).

The dashboard shows:

  • đŸ›ī¸ Storage Account (Emulator)
  • đŸ“Ļ Blob Containers
  • 🔄 Service interactions
  • 📊 Resource metrics

📋 Configuration Checklist

  • Infrastructure

  • Add enable_blob_storage = true to the feature_flags block in infra/api/app.generated.tf

  • Define container names in containers list
  • Obtain contributor group object ID
  • Apply Terraform configuration
  • Verify storage account created in Azure

  • Application

  • Add builder.AddAzureBlobServiceClient("blobstorage") to Program.cs

  • Implement blob upload/download endpoints using BlobServiceClient
  • Test with RBAC authentication

  • Local Development

  • Add Aspire storage emulator to AppHost with .AddAzureStorage().RunAsEmulator().AddBlobs("blobstorage")

  • Reference storage in backend service with .WithReference(blobs)
  • Test upload/download locally

  • Testing

  • Test blob upload functionality
  • Test blob download functionality
  • Verify RBAC permissions in Azure
  • Test with Aspire emulator locally

🚀 Deployment

Monitoring & Diagnostics

The storage account automatically includes:

  • 📊 Diagnostic Settings (Azure Monitor)
  • 📈 Metrics (requests, latency, errors)
  • 📝 Activity Logs (operations audit trail)
  • 🔍 Storage Analytics (capacity and transaction metrics)

❓ FAQ

Q: Can I use connection strings instead of RBAC?

A: Yes, but RBAC is recommended for production. Connection strings are best for local development with the storage emulator.

Q: How do I add more containers after initial deployment?

A: Update the containers list in the feature flags and reapply Terraform.

Q: What happens if a container name is invalid?

A: Terraform validation will fail with a helpful error message. Container names must be 3-63 characters, lowercase, and contain only alphanumeric characters and hyphens.

Q: Can I access storage from multiple services?

A: Yes, multiple services can reference the same storage account. Just ensure they all have appropriate RBAC roles assigned.

Q: How do I monitor blob storage usage?

A: Use Azure Portal's Storage Account monitoring, Azure Monitor dashboards, or configure custom alerts on metrics like capacity and transaction count.

Q: Why am I getting a 403 from the Azure Portal Storage Browser or az storage CLI?

A: The storage firewall is always on (#1003) and blocks access from outside SAIF's corporate/colocation IP ranges. You must access the storage data plane from a machine on the corporate network — VPN from home is not sufficient, since it doesn't egress from a SAIF corporate IP. This is the same constraint as Cosmos DB's Data Explorer; see VPN Access Does Not Work above.