Azure Blob Storage — Production File Storage with SAS URLs

Azure Blob Storage is the production file storage solution for the BlogApp — scalable, durable (11 nines of durability), and integrates with Azure CDN for global distribution. The IBlobStorageService abstraction allows local disk storage in development (fast, no Azure needed) and Azure Blob Storage in production — swapped via dependency injection configuration based on the environment.

IBlobStorageService Abstraction and Azure Implementation

// ── IBlobStorageService — swappable local vs Azure ─────────────────────────
public interface IBlobStorageService
{
    Task<string> UploadAsync(string container, string fileName,
                              Stream content, string mimeType,
                              CancellationToken ct = default);
    Task DeleteAsync(string container, string fileName, CancellationToken ct = default);
    string GetPublicUrl(string container, string fileName);
}

// ── Local storage implementation — development ─────────────────────────────
public class LocalBlobStorageService : IBlobStorageService
{
    private readonly string _rootPath;
    private readonly string _baseUrl;

    public LocalBlobStorageService(IWebHostEnvironment env, IConfiguration config)
    {
        _rootPath = Path.Combine(env.WebRootPath, "uploads");
        _baseUrl  = config["App:BaseUrl"] ?? "http://localhost:5000";
        Directory.CreateDirectory(_rootPath);
    }

    public async Task<string> UploadAsync(string container, string fileName,
        Stream content, string mimeType, CancellationToken ct)
    {
        var dir  = Path.Combine(_rootPath, container);
        Directory.CreateDirectory(dir);
        var path = Path.Combine(dir, fileName);
        using var fs = File.OpenWrite(path);
        await content.CopyToAsync(fs, ct);
        return GetPublicUrl(container, fileName);
    }

    public Task DeleteAsync(string container, string fileName, CancellationToken ct)
    {
        var path = Path.Combine(_rootPath, container, fileName);
        if (File.Exists(path)) File.Delete(path);
        return Task.CompletedTask;
    }

    public string GetPublicUrl(string container, string fileName) =>
        $"{_baseUrl}/uploads/{container}/{fileName}";
}

// ── Azure Blob Storage implementation — production ──────────────────────────
// Install: dotnet add package Azure.Storage.Blobs
public class AzureBlobStorageService : IBlobStorageService
{
    private readonly BlobServiceClient _client;
    private readonly string _cdnBaseUrl;

    public AzureBlobStorageService(IConfiguration config)
    {
        _client    = new BlobServiceClient(config["Azure:StorageConnectionString"]);
        _cdnBaseUrl = config["Azure:CdnBaseUrl"] ?? config["Azure:BlobBaseUrl"]!;
    }

    public async Task<string> UploadAsync(string container, string fileName,
        Stream content, string mimeType, CancellationToken ct)
    {
        var containerClient = _client.GetBlobContainerClient(container);
        await containerClient.CreateIfNotExistsAsync(PublicAccessType.Blob, ct);

        var blob = containerClient.GetBlobClient(fileName);
        await blob.UploadAsync(content, new BlobUploadOptions
        {
            HttpHeaders = new BlobHttpHeaders
            {
                ContentType = mimeType,
                CacheControl = "public, max-age=31536000, immutable",  // 1 year cache
            }
        }, ct);

        return GetPublicUrl(container, fileName);
    }

    public async Task DeleteAsync(string container, string fileName, CancellationToken ct)
    {
        var blob = _client.GetBlobContainerClient(container).GetBlobClient(fileName);
        await blob.DeleteIfExistsAsync(cancellationToken: ct);
    }

    public string GetPublicUrl(string container, string fileName) =>
        $"{_cdnBaseUrl}/{container}/{fileName}";
}

// ── Register in Program.cs ────────────────────────────────────────────────
if (builder.Environment.IsDevelopment())
    builder.Services.AddSingleton<IBlobStorageService, LocalBlobStorageService>();
else
    builder.Services.AddSingleton<IBlobStorageService, AzureBlobStorageService>();
Note: Setting Cache-Control: public, max-age=31536000, immutable on blob uploads tells browsers and CDNs to cache the image for one year and never revalidate — the immutable directive prevents the browser from checking if the file has changed. This is safe because image filenames are GUIDs — a “changed” image gets a new GUID filename, not an update to the existing file. Immutable caching with content-addressable URLs is the gold standard for static asset caching and dramatically reduces CDN bandwidth costs.
Tip: Use Managed Identity instead of a connection string for Azure Blob Storage in production. Replace the connection string with new BlobServiceClient(new Uri(blobServiceUri), new DefaultAzureCredential()). This eliminates stored credentials entirely — the App Service’s Managed Identity authenticates to the storage account. Add Storage Blob Data Contributor role to the App Service’s identity on the storage account. This is the most secure Azure storage integration pattern.
Warning: The Azure storage connection string contains a shared access key that grants full access to all blobs in the storage account. Never commit it to source control or include it in appsettings.json. Store it in environment variables (Azure__StorageConnectionString), Azure Key Vault, or use Managed Identity (no connection string needed). Rotate storage keys periodically using Azure Portal’s key regeneration feature, which invalidates the old key without downtime if you use SAS tokens or Managed Identity instead of keys directly.

Common Mistakes

Mistake 1 — Azure connection string in appsettings.json (committed to source control)

❌ Wrong — "StorageConnectionString": "DefaultEndpointsProtocol=https;AccountName=..." in appsettings; exposed in Git history.

✅ Correct — environment variable or Key Vault reference; never in committed files.

Mistake 2 — No Cache-Control header on blob uploads (every request hits origin)

❌ Wrong — blobs without Cache-Control; CDN fetches from origin on every cache miss; high bandwidth costs.

✅ Correct — Cache-Control: public, max-age=31536000, immutable; CDN caches for 1 year; 99%+ cache hit rate.

🧠 Test Yourself

A user uploads a new profile avatar. The old avatar blob URL is still stored in the database for other posts referencing it. Should the old blob be deleted when the user uploads a new avatar?