Azure Blob Storage For Private Files And Public Web Assets

Some random rambelings bumping my head with Azure Blob Storage with access to Private Files (App Internals) And Public Web Assets (Image the app displays)

I needed two different Azure Blob Storage setups for the same app: one for private chatbot uploads and one for public web assets that the browser could read directly. I wanted the upload flow to stay simple, but I also wanted the storage behaviour to be explicit.

This is the setup I ended up with, and the same shape is what I used to wire up the public background image upload for the chatbot UI.

1. Create the storage accounts with Terraform

I split this into two Terraform files so the private and public storage behaviours were obvious. The names need to be unique for the region, I replaced 00000001 and 000000002 with the end of a random GUID, example from 14db1e4e-5c64-4aa6-8de5-0f948e30ddee I would have used 0f948e30ddee.

The public assets account is the one I used for the chatbot background image. It needs public blob access enabled, because the browser reads the image directly from the blob URL.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# blob-storage-public-assets.tf
resource "azurerm_storage_account" "cb-asa-web-assets" {
name = "chatbotpublicd00000001"
resource_group_name = azurerm_resource_group.cb-rg.name
location = azurerm_resource_group.cb-rg.location
account_tier = "Standard"
account_replication_type = "LRS"

allow_nested_items_to_be_public = true
}

resource "azurerm_storage_container" "cb-asc-web-assets" {
name = "chatbot-web-assets"
storage_account_id = azurerm_storage_account.cb-asa-web-assets.id
container_access_type = "blob"
}

output "web_assets_container_base_url" {
value = "${azurerm_storage_account.cb-asa-web-assets.primary_blob_endpoint}${azurerm_storage_container.cb-asc-web-assets.name}"
description = "Base URL for public chatbot web assets"
}

The private storage account is for the existing chatbot file uploads. That one should stay private so the app can control access and keep those files out of the public path.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# blob-storage.tf
resource "azurerm_storage_account" "cb-asa" {
name = "chatbot000000002"
resource_group_name = azurerm_resource_group.cb-rg.name
location = azurerm_resource_group.cb-rg.location
account_tier = "Standard"
account_replication_type = "LRS"

allow_nested_items_to_be_public = false
}

resource "azurerm_storage_container" "cb-asc" {
name = "chatbot-files"
storage_account_id = azurerm_storage_account.cb-asa.id
container_access_type = "private"
}

The important difference is that the public account allows anonymous blob reads, while the private account does not.

Docs: Azure Blob Storage overviewAuthorize access to blobs

2. Find the connection string in Azure Portal

Once the storage accounts existed, I needed the connection string for each one. The easiest place to find it is in the Azure portal.

  1. Open the storage account in the Azure portal.
  2. Go to Security + networking.
  3. Open Access keys.
  4. Copy the Connection string value from one of the keys.

It will look like this:

1
DefaultEndpointsProtocol=https;AccountName=<storage-account-name>;AccountKey=<storage-key>;EndpointSuffix=core.windows.net

That is the exact value I used in app settings for the blob storage configuration. The key detail is that it must match the storage account that owns the container you want to use.

So for this app:

  • PrivateFiles.ConnectionString points at the storage account that owns chatbot-files
  • PublicFiles.ConnectionString points at the storage account that owns chatbot-web-assets

Docs: View account access keysConfigure Azure Storage connection strings

3. Put the values into app settings

In the app, I split the blob config into two sections so the code could stay explicit about which storage path it was using.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"AzureBlobStorage": {
"PrivateFiles": {
"ConnectionString": "DefaultEndpointsProtocol=https;AccountName=<ACCOUNT-NAME-HERE>;AccountKey=<ACCOUNT-KEY-HERE>;EndpointSuffix=core.windows.net",
"ContainerName": "chatbot-files"
},
"PublicFiles": {
"ConnectionString": "DefaultEndpointsProtocol=https;AccountName=<ACCOUNT-NAME-HERE>;AccountKey=<ACCOUNT-KEY-HERE>;EndpointSuffix=core.windows.net",
"ContainerName": "chatbot-web-assets"
}
},
"ChatUi": {
"PublicAssetsBaseUrl": "https://<ACCOUNT-NAME-HERE>.blob.core.windows.net/chatbot-web-assets"
}
}

That setup lets the app use one storage path for private files and another for public assets without mixing the two concerns together.

4. Wire the configuration into the C# app

The app uses an options class for the blob settings, and then registers a single storage service that knows about both containers.

1
2
3
4
5
6
7
8
// AzureBlobStorageOptions.cs
public sealed class AzureBlobStorageOptions
{
public const string SectionName = "AzureBlobStorage";

public AzureBlobStorageSettings PrivateFiles { get; init; } = new();
public AzureBlobStorageSettings PublicFiles { get; init; } = new();
}
1
2
3
// Program.cs
builder.Services.Configure<AzureBlobStorageOptions>(builder.Configuration.GetSection(AzureBlobStorageOptions.SectionName));
builder.Services.AddSingleton<IClientFileStorage, AzureBlobClientFileStorage>();

The storage implementation then creates two BlobContainerClient instances: one for the private container and one for the public container.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public sealed class AzureBlobClientFileStorage : IClientFileStorage
{
private readonly BlobContainerClient containerClient;
private readonly BlobContainerClient publicAssetsContainerClient;

public AzureBlobClientFileStorage(IOptions<AzureBlobStorageOptions> options)
{
var settings = options.Value;

containerClient = new BlobContainerClient(
settings.PrivateFiles.ConnectionString,
settings.PrivateFiles.ContainerName);

publicAssetsContainerClient = new BlobContainerClient(
settings.PublicFiles.ConnectionString,
settings.PublicFiles.ContainerName);
}
}

That is the core of the split: private uploads stay on the private client, while public assets use the public client.

Docs: ASP.NET Core configurationAzure.Storage.Blobs client library

5. Upload the background image from onboarding

The onboarding flow now accepts an uploaded image, validates it, and sends it to the public blob container.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private async Task UploadBackgroundImageAsync(Guid clientUid, IFormFile backgroundImage, CancellationToken cancellationToken)
{
await using var imageStream = backgroundImage.OpenReadStream();
ValidateUploadImageDimensions(imageStream, backgroundImage.FileName);
imageStream.Position = 0;

var blobPath = $"{clientUid:D}/BotBackground.png";
await clientFileStorage.UploadBackgroundImageAsync(
clientUid,
"BotBackground.png",
imageStream,
backgroundImage.ContentType,
cancellationToken);

await clientRepository.SetBackgroundImageAsync(clientUid, blobPath, cancellationToken);
}

The validation step is intentionally strict so the image dimensions match the expected hero image size.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void ValidateUploadImageDimensions(Stream imageStream, string fileName)
{
var extension = Path.GetExtension(fileName ?? string.Empty).ToLowerInvariant();
if (extension is not ".png" and not ".jpg" and not ".jpeg")
{
throw new InvalidOperationException("Only PNG or JPG files are supported.");
}

using var image = Image.Load(imageStream);
if (image.Width != 1402 || image.Height != 1122)
{
throw new InvalidOperationException("Background image must be exactly 1402x1122 pixels.");
}
}

That means the app expects a very specific image size before it will accept the upload.

6. Serve the uploaded image in chat

Once the image is uploaded, the app stores the relative blob path and builds a URL for the chat page. The public base URL comes from the ChatUi section.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private static string BuildBackgroundImageUrl(ChatUiOptions chatUiSettings, string? backgroundImagePath)
{
if (!string.IsNullOrWhiteSpace(backgroundImagePath))
{
if (Uri.TryCreate(backgroundImagePath, UriKind.Absolute, out _))
{
return backgroundImagePath;
}

if (!string.IsNullOrWhiteSpace(chatUiSettings.PublicAssetsBaseUrl))
{
var baseUrl = chatUiSettings.PublicAssetsBaseUrl.Trim().TrimEnd('/');
var normalizedPath = backgroundImagePath.Trim().Trim('/');
return $"{baseUrl}/{normalizedPath}";
}
}

return "/images/chat/BotBackgroundDefault.png";
}

That is what lets the chatbot render a per-bot background image when one exists, and fall back to a local default image when it does not.

7. What I learned

The key thing for me was to keep the two storage paths separate from the start. If I had used a single blob account for both private files and public assets, the configuration would have become ambiguous very quickly.

Using two storage accounts and two config sections made the app easier to reason about:

  • private uploads stay private
  • public assets can be served directly by URL
  • the connection string is easy to find and easy to swap if the environment changes

That is the shape I would use again if I needed the same pattern in another app.