---
title: Blob Storage
description: Provision Blob Storage and connect Forge services locally and in Azure.
moved_from:
  - guides/development/data/storage-account.md
---

# 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](../../learn/migration/forge-v2-to-v3.md).

First, look up your team's Entra ID group object ID:

```powershell
az ad group show --group "<group display name>" --query id -o tsv
```

Copy the returned GUID into `contributor_group_id` in the feature flag configuration.

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

```hcl
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.

> ℹ️ **Already have a `feature_flags` block?** Add these members to the existing object instead of adding a second block; see [merging feature flags](../../reference/terraform/saif-api-service/index.md#feature-flags).

**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](#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. Paste the literal GUID returned by the command above 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:

```hcl
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](https://learn.microsoft.com/azure/storage/common/storage-network-security#trusted-access-based-on-a-resource-instance)
> for the full exception scope.

!!! warning "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](cosmos-nosql.md#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

```csharp
// 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:

```csharp
// 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

```csharp
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

```bash
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` ([merge into an existing object](../../reference/terraform/saif-api-service/index.md#feature-flags) if one is already generated)
  - [ ] 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)

## 🔗 Related Resources

- [Azure Blob Storage Documentation](https://learn.microsoft.com/en-us/azure/storage/blobs/)
- [Aspire Storage Integration](https://learn.microsoft.com/en-us/dotnet/aspire/components/azure-storage-blobs)
- [Azure Identity & RBAC](https://learn.microsoft.com/en-us/azure/role-based-access-control/)
- [Example: aspire-blobstorage](../examples/aspire-blobstorage.md)

## ❓ 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](#network-isolation) above.
