Intermediate AZ-204

AZ-204: Working with Cloud Storage and Databases

Blob Storage, Table Storage, Cosmos DB and Redis SDKs, partitioning, lifecycle rules, CDN and static hosting.

Level: Intermediate
Last updated: June 2026

Module 1 – Configuring Azure Blob Storage

Blob Storage overview

  • Object storage in Azure: hosts any type of file at low cost.
  • Each blob has a unique URL. Security configurable for authorized access only.
  • Resilient and scalable architecture: multiple copies across multiple servers, multi-region redundancy possible.
  • Use cases: media streaming, logs (append blobs), VHD backups, data lakes, Azure Databricks, Power BI.

The 3 blob types

TypeDescriptionMax size
Block BlobText or binary files, broken into blocks uploadable in parallel. Modifiable (insert/replace/delete blocks).190.7 TB
Append BlobBlocks added only at the end of file. Ideal for log files.195 GB
Page BlobCollection of 512-byte pages, optimized for random reads/writes. Used for Azure VM VHDs.8 TB
  • Important: The blob type is defined at creation and cannot be changed.
  • It is possible to lease a blob (exclusive write lock), create snapshots, and apply immutability policies.

Storage account types

  • General Purpose V2 (GPV2): recommended for most cases. Supports blobs, files, tables, queues.
  • Premium: SSD performance for ultra-low latency scenarios (page blobs, file shares, block blobs).

Storage account configuration

  • Naming: 3–24 characters, lowercase letters and digits only, must be unique across Azure (part of the URL).
  • Region: choose based on user location.
  • Redundancy: LRS, ZRS, GRS, GZRS.

Containers and access

  • Blobs are organized into containers.
  • Container access levels:
    • Private (recommended): authorization required.
    • Blob: anonymous access to blobs only.
    • Container: anonymous access to blobs and container listing.

Blob security and permissions

MethodRecommendation
Anonymous accessNot recommended. Use only for public files.
RBAC with Entra ID✅ Recommended. Assign roles to security principals (users, groups, apps). Possible at account or container level.
Managed Identity✅ For apps hosted on Azure (VM, App Service, AKS). No credentials to store.
Storage Account KeyMaster key. 2 keys for rotation. Do not share or hardcode.
Shared Access Signature (SAS)Time-limited access. Can target precise permissions (read/write). Can be scoped at blob level.

Access Tiers

Applicable only to block blobs:

TierUsageStorage costAccess costMinimum duration
HotFrequently accessed dataHighLow
CoolLess frequent dataMediumMedium30 days
ColdRarely accessed dataLowHigh90 days
ArchiveLong-term backups, retrieval in hoursVery lowVery high180 days

Lifecycle Management

  • Define rules to automatically move or delete blobs based on:
    • Age (days since last modification)
    • Last access
    • Current tier
  • Example: Hot → Cold if not modified in 30 days → Archive if not accessed in 90 days.
  • Filters: by name prefix or blob index tags.

Blob Index Tags

  • Key/value pairs attached to blobs.
  • Allow organizing blobs across different containers.
  • Usable as filters in lifecycle policies.
  • Max 10 tags per blob. Require additional permissions to be modified.

Blob properties and metadata

  • Properties: standard HTTP headers (Cache-Control, Content-Type, Content-Disposition, Content-Encoding) + specific properties (access tier, archive status, legal hold).
  • Metadata: custom key/value pairs. Max 8 KB total. No limit on the number of items. Stored with the blob, returned as HTTP headers.
  • Difference from Index Tags: metadata does not support built-in search (requires Azure Search). Index tags have built-in search but are limited to 10.

Module 2 – Developing Blob Storage Solutions

Azure Storage Client Libraries (SDK)

  • Packages available for .NET, Java, Python, Go, JavaScript (Node.js/browser).
  • Encapsulate Azure REST API calls.
  • Main .NET classes:
    • BlobServiceClient: connection to the storage account.
    • BlobContainerClient: operations on a container.
    • BlobClient: operations on an individual blob.
    • AppendBlobClient, PageBlobClient: specific operations for those types.

Authentication with Entra ID (code)

  • Use DefaultAzureCredential from the Azure.Identity library.
  • In local development: uses the account connected to Azure CLI (az login).
  • In Azure production: automatically uses the Managed Identity of the service.
// Recommended connection (no hardcoded credentials)
var credential = new DefaultAzureCredential();
var client = new BlobServiceClient(
    new Uri($"https://{accountName}.blob.core.windows.net"),
    credential);

Generating a SAS token (code)

// 1. Get a User Delegation Key (valid for the SAS duration)
var userDelegationKey = await blobServiceClient
    .GetUserDelegationKeyAsync(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(1));

// 2. Create a BlobSasBuilder
var sasBuilder = new BlobSasBuilder
{
    BlobContainerName = containerName,
    Resource = "c", // "c" = container, "b" = blob
    ExpiresOn = DateTimeOffset.UtcNow.AddDays(1)
};
sasBuilder.SetPermissions(BlobContainerSasPermissions.Read | BlobContainerSasPermissions.Write | BlobContainerSasPermissions.List);

// 3. Create the URI with the signed SAS token
var sasUri = new BlobUriBuilder(containerClient.Uri)
{
    Sas = sasBuilder.ToSasQueryParameters(userDelegationKey, accountName)
}.ToUri();

Retry Policy (BlobClientOptions)

var options = new BlobClientOptions
{
    Retry = {
        Delay = TimeSpan.FromSeconds(0.8),    // Delay between attempts (default: 0.8s)
        MaxRetries = 5,                        // Max attempts (default: 5)
        Mode = RetryMode.Exponential,          // Fixed or Exponential
        MaxDelay = TimeSpan.FromMinutes(1),    // Max delay (default: 1 min)
        NetworkTimeout = TimeSpan.FromSeconds(100) // Network timeout (default: 100s)
    }
};

Geo-redundancy for fault tolerance

  • Configure GRS or GZRS at account creation.
  • In the event of a primary datacenter outage, the secondary takes over.
  • The client library can be configured to redirect to the secondary if the primary fails.

Azure Functions + Blob Storage

  • Blob Trigger: triggers the function when a blob is uploaded to a container.
  • Blob Input Binding: reads a blob as function input.
  • BlobOutput Binding: writes a blob as output (function return).
// Example of Blob Trigger + Blob Output
[FunctionName("ThumbnailGenerator")]
[return: BlobOutput("thumbnails/{name}", Connection = "AzureWebJobsStorage")]
public async Task<byte[]> Run(
    [BlobTrigger("images/{name}", Connection = "AzureWebJobsStorage")] Stream imageStream,
    ILogger log)
{
    // Process the image → return a thumbnail as bytes
}

Module 3 – Configuring Azure Cosmos DB

Cosmos DB overview

  • Globally distributed NoSQL database, horizontally scalable.
  • Multimodal: supports multiple APIs.
  • Multi-region replication with configurable consistency levels.
  • Guaranteed SLAs: latency < 10ms at P99 for reads/writes in the primary region.

Available APIs

APIUsageQuery language
NoSQL (Core SQL)Native Cosmos DB API. All features available. JSON documents.SQL-like
MongoDBMongoDB protocol emulation. Easy migration.MongoDB Query Language
CassandraCassandra emulation. Wide-column data.CQL
Apache GremlinGraph data (nodes + edges).Gremlin
PostgreSQLDistributed with PostgreSQL.SQL
Table APICompatible with Azure Table Storage.OData
  • Important for the exam: choose the NoSQL API for new Cosmos DB projects (access to all features).

Cosmos DB data model (NoSQL API)

  • AccountDatabaseContainerItems (JSON documents)
  • Partition Key: document property that determines the physical partition. Critical for performance.
  • Request Units (RUs): abstraction of operation cost (CPU, memory, I/O). Reading 1 KB = 1 RU.
  • Throughput: configured in RU/s. Can be Provisioned (guaranteed) or Serverless (on-demand).

Consistency levels

From strongest to weakest:

LevelGuaranteeLatencyThroughput
StrongAlways reads the most recent data. Synchronized across all regions.HighLow
Bounded StalenessData within defined bounds (N versions or T seconds). Order guaranteed.MediumMedium
Session (default)Strong consistency for the same client/session.LowHigh
Consistent PrefixOrder guaranteed, no staleness bound.LowHigh
EventualNo order or freshness guarantees. Best availability.Very lowVery high
  • Strong is not available with multi-region writes.
  • The level can be changed in the Azure portal.

Module 4 – Developing Cosmos DB Solutions

Cosmos DB .NET SDK

// Connection with Entra ID (recommended in production)
var client = new CosmosClient(
    accountEndpoint: "<uri>",
    tokenCredential: new DefaultAzureCredential(),
    clientOptions: new CosmosClientOptions { ApplicationName = "MyApp" });

// Basic CRUD
var container = client.GetContainer("devdb", "users");

// Create an item
await container.CreateItemAsync(newUser, new PartitionKey(newUser.Id));

// Read an item
var response = await container.ReadItemAsync<User>(id, new PartitionKey(partitionKeyValue));

// SQL query
var query = new QueryDefinition("SELECT * FROM c WHERE c.email = @email")
    .WithParameter("@email", email);
using var feed = container.GetItemQueryIterator<User>(query);
while (feed.HasMoreResults)
{
    var results = await feed.ReadNextAsync();
    // processing
}

// Delete an item
await container.DeleteItemAsync<User>(id, new PartitionKey(partitionKeyValue));

RBAC roles for Cosmos DB (Entra ID)

  • Cosmos DB Built-in Data Reader: read-only.
  • Cosmos DB Built-in Data Contributor: read/write.
  • Assignment via Azure CLI:
az cosmosdb sql role assignment create \
  --resource-group <rg> --account-name <account> \
  --role-definition-name "Cosmos DB Built-in Data Contributor" \
  --principal-id <user-or-sp-id> \
  --scope "/dbs/<database>/colls/<container>"

Change Feed

  • Ordered log (by partition key) of all inserts and updates in a container.
  • By default: deletes are NOT included (use soft delete + TTL).
  • Useful for: synchronizing denormalized data, building projections, notifying systems in real time.

Change Feed modes

ModeDescription
Latest Version (default)Only inserts and updates. Returns the latest version of each item.
All Versions and DeletesPreview. Also includes deletions.

Lease Container

  • Separate container that stores the last read position of the change feed.
  • Prevents reprocessing the same data on restart.

Azure Functions – Change Feed Trigger

[FunctionName("OrderTrigger")]
public async Task Run(
    [CosmosDBTrigger(
        databaseName: "devdb",
        containerName: "orders",
        Connection = "CosmosConnection",
        LeaseContainerName = "leases",
        CreateLeaseContainerIfNotExists = true)] IReadOnlyList<Order> changes,
    ILogger log)
{
    foreach (var order in changes)
    {
        // Process each modified order
    }
}

Change Feed Processor Library

  • For full control, self-hosted.
  • Use GetChangeFeedProcessorBuilder() to create the processor.
  • Define a processing handler (HandleChangesAsync).
  • Example: synchronize category names across all associated products.

Key points for the exam

Blob Storage

  • DefaultAzureCredential: locally → Azure CLI login; in Azure → Managed Identity.
  • RBAC only works at the container level (not blob). For blob level → use SAS.
  • SAS token recommended with User Delegation Key (rather than the storage account key).
  • Blob access tiers: Hot, Cool (30d), Cold (90d), Archive (180d).
  • Metadata = unlimited count, max 8 KB total. Index Tags = max 10 tags, built-in search.

Cosmos DB

  • Choose the NoSQL API for new applications.
  • The partition key is critical for performance.
  • 1 RU = reading a 1 KB document.
  • Change Feed = log of inserts/updates (no deletions by default).
  • Bounded Staleness = guaranteed order + freshness within defined bounds.
  • Session = strong consistency for the same client, low latency.
  • Strong = incompatible with multi-region writes.

Module 5 – Azure Blob Storage SDK (AZ-204 exam focus)

Client class hierarchy

graph TD
    A[BlobServiceClient\nstorage account] --> B[BlobContainerClient\ncontainer]
    B --> C[BlobClient\ngeneric blob]
    B --> D[AppendBlobClient\nappend blob]
    B --> E[PageBlobClient\npage blob]
ClassResponsibilityTypical instantiation
BlobServiceClientConnection to the storage account, container managementnew BlobServiceClient(uri, credential)
BlobContainerClientOperations on a container (create, list, delete)serviceClient.GetBlobContainerClient("my-container")
BlobClientCRUD on a blob (upload, download, delete, metadata)containerClient.GetBlobClient("file.txt")

Create a container and upload a blob

using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Azure.Identity;

// Connection with Entra ID (DefaultAzureCredential)
var serviceClient = new BlobServiceClient(
    new Uri("https://<account>.blob.core.windows.net"),
    new DefaultAzureCredential());

// Create the container (if it doesn't exist)
var containerClient = serviceClient.GetBlobContainerClient("images");
await containerClient.CreateIfNotExistsAsync(PublicAccessType.None);

// Upload a local file
var blobClient = containerClient.GetBlobClient("photo.jpg");
await using var fileStream = File.OpenRead(@"C:\temp\photo.jpg");
await blobClient.UploadAsync(fileStream, overwrite: true);

// Upload content from memory with Content-Type
var uploadOptions = new BlobUploadOptions
{
    HttpHeaders = new BlobHttpHeaders { ContentType = "image/jpeg" }
};
await blobClient.UploadAsync(BinaryData.FromString("text content"), uploadOptions);

Download a blob

// Download to a local file
await blobClient.DownloadToAsync(@"C:\temp\photo_downloaded.jpg");

// Download in memory (BinaryData)
var response = await blobClient.DownloadContentAsync();
string content = response.Value.Content.ToString();

// Download as Stream
var downloadInfo = await blobClient.DownloadStreamingAsync();
using var outputStream = File.OpenWrite(@"C:\temp\output.jpg");
await downloadInfo.Value.Content.CopyToAsync(outputStream);

List blobs

// List all blobs in a container (with metadata)
await foreach (var blobItem in containerClient.GetBlobsAsync(BlobTraits.Metadata))
{
    Console.WriteLine($"Name: {blobItem.Name} | Size: {blobItem.Properties.ContentLength}");
    foreach (var meta in blobItem.Metadata)
        Console.WriteLine($"  {meta.Key} = {meta.Value}");
}

// List by prefix (simulates a folder)
await foreach (var blobItem in containerClient.GetBlobsAsync(prefix: "folder/subfolder/"))
{
    Console.WriteLine(blobItem.Name);
}

Delete a blob

// Simple deletion
await blobClient.DeleteIfExistsAsync();

// Deletion with snapshots
await blobClient.DeleteAsync(DeleteSnapshotsOption.IncludeSnapshots);

Metadata and properties

// Set custom metadata
var metadata = new Dictionary<string, string>
{
    { "category", "report" },
    { "department", "finance" }
};
await blobClient.SetMetadataAsync(metadata);

// Read properties and metadata
var props = await blobClient.GetPropertiesAsync();
Console.WriteLine($"Content-Type: {props.Value.ContentType}");
Console.WriteLine($"Tier: {props.Value.AccessTier}");
foreach (var kv in props.Value.Metadata)
    Console.WriteLine($"  {kv.Key} = {kv.Value}");

// Modify HTTP properties
await blobClient.SetHttpHeadersAsync(new BlobHttpHeaders
{
    ContentType        = "application/pdf",
    CacheControl       = "max-age=3600",
    ContentDisposition = "attachment; filename=\"report.pdf\""
});

Leases (locks)

// Acquire a lease (exclusive write lock, 60s duration)
var leaseClient = blobClient.GetBlobLeaseClient();
var leaseResponse = await leaseClient.AcquireAsync(TimeSpan.FromSeconds(60));
string leaseId = leaseResponse.Value.LeaseId;

// Operation protected by the lease
var conditions = new BlobRequestConditions { LeaseId = leaseId };
await blobClient.SetMetadataAsync(metadata, conditions);

// Release the lease
await leaseClient.ReleaseAsync();

// Renew the lease before expiry
await leaseClient.RenewAsync();

SAS: User Delegation SAS vs Service SAS

CriterionUser Delegation SASService SAS
Signed byUser Delegation Key (obtained via Entra ID)Storage Account Key
Recommended✅ Yes — does not require the account key⚠️ Less recommended
RevocableYes (revoke the User Delegation Key)No (except key rotation)
ScopeContainer or blobBlob, container, file, queue, table
PrerequisitesStorage Blob Delegator role on the accountAccount key available
// ── User Delegation SAS ──────────────────────────────────────
var udKey = await serviceClient.GetUserDelegationKeyAsync(
    startsOn:  DateTimeOffset.UtcNow,
    expiresOn: DateTimeOffset.UtcNow.AddHours(4));

var sasBuilder = new BlobSasBuilder
{
    BlobContainerName = "images",
    BlobName          = "photo.jpg",
    Resource          = "b",   // "b" = blob, "c" = container
    StartsOn          = DateTimeOffset.UtcNow,
    ExpiresOn         = DateTimeOffset.UtcNow.AddHours(4)
};
sasBuilder.SetPermissions(BlobSasPermissions.Read | BlobSasPermissions.Write);

var uriBuilder = new BlobUriBuilder(blobClient.Uri)
{
    Sas = sasBuilder.ToSasQueryParameters(udKey, serviceClient.AccountName)
};
Uri sasUri = uriBuilder.ToUri();  // → distribute this temporary link

// ── Service SAS (with account key) ──────────────────────
var storageSharedKey = new StorageSharedKeyCredential("<account>", "<key>");
var serviceSasBuilder = new BlobSasBuilder
{
    BlobContainerName = "images",
    BlobName          = "photo.jpg",
    Resource          = "b",
    ExpiresOn         = DateTimeOffset.UtcNow.AddHours(1)
};
serviceSasBuilder.SetPermissions(BlobSasPermissions.Read);
string sasToken = serviceSasBuilder.ToSasQueryParameters(storageSharedKey).ToString();

Lifecycle policy via SDK

using Azure.Storage.Blobs.Models;
using Azure.ResourceManager.Storage;
using Azure.ResourceManager.Storage.Models;

// Create a lifecycle policy via ARM (Azure Resource Manager)
var policy = new StorageAccountManagementPolicyData();
policy.Policy = new ManagementPolicySchema();
policy.Policy.Rules.Add(new ManagementPolicyRule("archive-old-blobs", ManagementPolicyRuleType.Lifecycle)
{
    Definition = new ManagementPolicyDefinition
    {
        Actions = new ManagementPolicyAction
        {
            BaseBlob = new ManagementPolicyBaseBlob
            {
                TierToCool    = new DateAfterModification { DaysAfterModificationGreaterThan = 30 },
                TierToArchive = new DateAfterModification { DaysAfterModificationGreaterThan = 90 },
                Delete        = new DateAfterModification { DaysAfterModificationGreaterThan = 365 }
            }
        },
        Filters = new ManagementPolicyFilter
        {
            BlobTypes  = { "blockBlob" },
            PrefixMatch = { "logs/" }
        }
    }
});

Module 6 – Azure Table Storage

Overview

Azure Table Storage is a NoSQL key-value storage service for large amounts of semi-structured data. It is part of the GPv2 storage account and accessible via the Azure.Data.Tables SDK.

graph LR
    A[Storage account] --> B[Table]
    B --> C[Entity\nPartitionKey + RowKey + properties]
ConceptDescription
TableCollection of entities (equivalent to a SQL table, but without a fixed schema)
EntityRow of data. Max 1 MB. Up to 252 custom properties + 3 system
PartitionKeyPartitioning key — groups entities in the same physical partition
RowKeyUnique identifier within a partition — lexicographic sort
ETagEntity version for optimistic conflict management

TableServiceClient and TableClient

using Azure.Data.Tables;
using Azure.Identity;

// Connection with Entra ID
var serviceClient = new TableServiceClient(
    new Uri("https://<account>.table.core.windows.net"),
    new DefaultAzureCredential());

// Create a table
await serviceClient.CreateTableIfNotExistsAsync("customers");

// Get a client on an existing table
var tableClient = serviceClient.GetTableClient("customers");

Entity model

// Implements ITableEntity (or inherits from TableEntity)
public class CustomerEntity : ITableEntity
{
    public string PartitionKey { get; set; }  // e.g.: country code "US"
    public string RowKey { get; set; }        // e.g.: customer identifier "C001"
    public string Name { get; set; }
    public string Email { get; set; }
    public DateTimeOffset? Timestamp { get; set; }
    public ETag ETag { get; set; }
}

Complete CRUD

var entity = new CustomerEntity
{
    PartitionKey = "US",
    RowKey       = "C001",
    Name         = "Acme Corp",
    Email        = "contact@acme.com"
};

// CREATE
await tableClient.AddEntityAsync(entity);

// READ (point read — very performant)
var result = await tableClient.GetEntityAsync<CustomerEntity>("US", "C001");
Console.WriteLine(result.Value.Name);

// UPDATE (Merge = only sent properties are updated)
entity.Email = "newmail@acme.com";
await tableClient.UpdateEntityAsync(entity, entity.ETag, TableUpdateMode.Merge);

// REPLACE (overwrites all properties)
await tableClient.UpdateEntityAsync(entity, ETag.All, TableUpdateMode.Replace);

// UPSERT (creates or replaces based on existence)
await tableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace);

// DELETE
await tableClient.DeleteEntityAsync("US", "C001");

Filters and pagination with continuation tokens

// Simple OData filter
string filter = TableClient.CreateQueryFilter<CustomerEntity>(
    e => e.PartitionKey == "US" && e.Name == "Acme Corp");

// Paginated query (max 1000 entities per page)
string? continuationToken = null;
do
{
    var page = tableClient
        .QueryAsync<CustomerEntity>(filter: filter, maxPerPage: 100)
        .AsPages(continuationToken);

    await foreach (var tablePage in page)
    {
        foreach (var e in tablePage.Values)
            Console.WriteLine($"{e.PartitionKey}/{e.RowKey}: {e.Name}");

        continuationToken = tablePage.ContinuationToken;
    }
} while (continuationToken != null);

// Query by PartitionKey only (within a single partition → performant)
await foreach (var e in tableClient.QueryAsync<CustomerEntity>(
    e => e.PartitionKey == "US"))
{
    Console.WriteLine(e.Name);
}

Batch operations (TransactionalBatch)

Batch operations must share the same PartitionKey (Table Storage limitation).

// Batch: all entities must have the same PartitionKey
var batch = new List<TableTransactionAction>
{
    new TableTransactionAction(TableTransactionActionType.Add, new CustomerEntity
        { PartitionKey = "EU", RowKey = "C010", Name = "TechBerlin" }),
    new TableTransactionAction(TableTransactionActionType.Add, new CustomerEntity
        { PartitionKey = "EU", RowKey = "C011", Name = "CloudParis" }),
    new TableTransactionAction(TableTransactionActionType.Delete,
        new CustomerEntity { PartitionKey = "EU", RowKey = "C009", ETag = ETag.All })
};

// Max 100 operations per batch, max 4 MB
var responses = await tableClient.SubmitTransactionAsync(batch);
foreach (var r in responses.Value)
    Console.WriteLine($"Status: {r.Status}");

PartitionKey / RowKey design

flowchart TD
    A[Goal: minimize cross-partition queries] --> B{What access\nis most frequent?}
    B -->|By unique entity| C[PartitionKey = parent entity\nRowKey = unique ID]
    B -->|By date/range| D[PartitionKey = YYYY-MM\nRowKey = inverted timestamp\nnnnnnnnnn - ticks]
    B -->|By category| E[PartitionKey = category\nRowKey = GUID]
Best practiceExplanation
Avoid a single global PartitionKeyCreates a hot partition, limits throughput
Avoid too many tiny partitionsHurts scans that cross multiple partitions
Inverted RowKeystring.MaxValue - DateTime.UtcNow.Ticks → most recent entries first
Limit entity sizeMax 1 MB, ideally < 50 KB

Module 7 – Azure Cosmos DB SDK (deep dive)

CosmosClient — single instance (Singleton)

AZ-204 golden rule: CosmosClient is expensive to create (establishes persistent TCP connections). It must be instantiated only once and reused (dependency injection → Singleton).

// Program.cs — Singleton registration for ASP.NET Core / Azure Functions
builder.Services.AddSingleton(_ => new CosmosClient(
    accountEndpoint: builder.Configuration["CosmosDb:Endpoint"],
    tokenCredential: new DefaultAzureCredential(),
    clientOptions: new CosmosClientOptions
    {
        ApplicationName          = "MyApp",
        ConnectionMode           = ConnectionMode.Direct,  // Direct = lower latency
        SerializerOptions        = new CosmosSerializationOptions
        {
            PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
        }
    }));

Create a database and container

// Get or create the database
Database db = await cosmosClient.CreateDatabaseIfNotExistsAsync("ecommerce");

// Create a container with partition key and throughput
ContainerProperties props = new ContainerProperties
{
    Id           = "products",
    PartitionKeyPath = "/category"   // must start with "/"
};
Container container = await db.CreateContainerIfNotExistsAsync(
    containerProperties: props,
    throughput: 400);                  // 400 RU/s minimum

Complete CRUD

public record Product(string id, string category, string name, decimal price);

// CREATE
var product = new Product("p-001", "electronics", "Mechanical keyboard", 149.99m);
ItemResponse<Product> createResp = await container.CreateItemAsync(
    product, new PartitionKey(product.category));
Console.WriteLine($"RUs consumed: {createResp.RequestCharge}");

// READ — point read (1 RU for 1 KB, very performant)
ItemResponse<Product> readResp = await container.ReadItemAsync<Product>(
    id: "p-001", partitionKey: new PartitionKey("electronics"));
Product p = readResp.Resource;

// REPLACE (replaces the entire document)
var updated = p with { price = 129.99m };
await container.ReplaceItemAsync(updated, updated.id, new PartitionKey(updated.category));

// UPSERT (creates or replaces)
await container.UpsertItemAsync(updated, new PartitionKey(updated.category));

// PATCH (partial modification — .NET SDK v3.23+)
await container.PatchItemAsync<Product>(
    id: "p-001",
    partitionKey: new PartitionKey("electronics"),
    patchOperations: new[] { PatchOperation.Replace("/price", 119.99m) });

// DELETE
await container.DeleteItemAsync<Product>(
    id: "p-001", partitionKey: new PartitionKey("electronics"));

Point reads vs Queries

CriterionPoint Read (ReadItemAsync)Query (GetItemQueryIterator)
Cost~1 RU (1 KB)Variable depending on complexity
Requiresid + PartitionKeyParameterized SQL query
PerformanceO(1) — direct accessPossible scan if no PartitionKey
UsageRetrieve a known itemFilter, aggregate, list

Parameterized queries (QueryDefinition)

// Parameterized query — prevents injection and improves performance
var query = new QueryDefinition(
    "SELECT * FROM c WHERE c.category = @cat AND c.price < @maxPrice")
    .WithParameter("@cat",      "electronics")
    .WithParameter("@maxPrice", 200.0);

using FeedIterator<Product> iterator = container.GetItemQueryIterator<Product>(
    query,
    requestOptions: new QueryRequestOptions
    {
        PartitionKey  = new PartitionKey("electronics"), // avoids cross-partition
        MaxItemCount  = 50                               // page size
    });

while (iterator.HasMoreResults)
{
    FeedResponse<Product> page = await iterator.ReadNextAsync();
    Console.WriteLine($"Page RUs: {page.RequestCharge}");
    foreach (var item in page)
        Console.WriteLine($"{item.name} — {item.price:C}");
}

Change Feed Processor

// Build and start the processor
ChangeFeedProcessor processor = container
    .GetChangeFeedProcessorBuilder<Product>(
        processorName: "syncPrices",
        onChangesDelegate: async (changes, cancellationToken) =>
        {
            foreach (var product in changes)
                Console.WriteLine($"Change detected: {product.name}");
        })
    .WithInstanceName("instance-1")
    .WithLeaseContainer(leaseContainer)   // separate leases container
    .WithStartTime(DateTime.UtcNow)       // optional: start at a specific time
    .Build();

await processor.StartAsync();

// ... background processing ...

await processor.StopAsync();

Module 8 – Cosmos DB: Partitioning

Why partition key choice is critical

flowchart LR
    A[Item written] --> B{Hash of\nPartitionKey}
    B --> C[Physical partition 1\nmax ~50 GB / ~10000 RU/s]
    B --> D[Physical partition 2]
    B --> E[Physical partition N]
  • Cosmos DB distributes items between physical partitions via a hash of the PartitionKey.
  • Each physical partition can handle up to ~10,000 RU/s and store ~50 GB.
  • A bad partition key can create a hot partition (bottleneck).

Selection criteria

CriterionRecommendation
High cardinalityPrefer a property with many distinct values (e.g.: userId, orderId)
Uniform distributionEach value should represent approximately the same volume of traffic/data
Included in frequent queriesAvoids costly cross-partition queries
ImmutableThe PK cannot be changed after item creation
Avoid overly generic values"active"/"inactive" → hot partition almost certain

Cross-Partition Queries

// ❌ Cross-partition (expensive — scans all partitions)
var query = new QueryDefinition("SELECT * FROM c WHERE c.name = @name")
    .WithParameter("@name", "Mechanical keyboard");
// No PartitionKey in QueryRequestOptions → Cosmos DB scans everything

// ✅ Single-partition (performant)
var query = new QueryDefinition("SELECT * FROM c WHERE c.category = @cat AND c.name = @name")
    .WithParameter("@cat", "electronics")
    .WithParameter("@name", "Mechanical keyboard");
var opts = new QueryRequestOptions { PartitionKey = new PartitionKey("electronics") };

Hot Partitions — Detection and remedies

SymptomProbable causeSolution
429 errors (TooManyRequests) on certain partitionsLow cardinality PartitionKeyChange the PK or add a random suffix
Overall throughput underutilized but one partition saturatedVery dominant PK value (e.g.: "admin")Synthetic partition key
High latency on a subset of itemsToo popular keyHierarchical partition keys

Synthetic Partition Keys

// Problem: orderId = GUID, but all orders from a customer go to the same place
// Solution: combine multiple attributes to create a synthetic key

public record Order
{
    public string id { get; init; }
    public string customerId { get; init; }
    public string region { get; init; }
    // Synthetic key: distributes evenly AND allows queries by customer+region
    public string pk => $"{customerId}_{region}";
}

// Or add a numeric suffix for high-throughput containers
string pk = $"{userId}_{new Random().Next(1, 10)}";  // distributes across 10 logical partitions

Hierarchical Partition Keys (SDK v3.23+)

// Up to 3 levels of partition key (preview → GA)
ContainerProperties props = new ContainerProperties("orders", "/tenantId")
{
    PartitionKeyDefinition = new PartitionKeyDefinition
    {
        Paths = { "/tenantId", "/customerId", "/orderId" },
        Version = PartitionKeyDefinitionVersion.V2,
        Kind = PartitionKind.MultiHash
    }
};

// Query on the first level only (efficient)
var opts = new QueryRequestOptions
{
    PartitionKey = new PartitionKeyBuilder()
        .Add("tenant-A")
        .Build()
};

Module 9 – Azure Cache for Redis SDK

Architecture and use cases

graph LR
    App[Application\nASP.NET Core] -->|IConnectionMultiplexer| Redis["(Azure Cache\nfor Redis)"]
    Redis -->|StringGet/HashGet/etc.| App
    App -->|Cache-aside pattern| DB["(Database\nSQL / Cosmos DB)"]
Use caseDescription
Session cacheStore user session state (faster than database)
Data cacheReduce repetitive reads on rarely changing data
Rate limitingCount requests by IP with automatic expiration
Distributed lockCoordinate access to a shared resource between instances
Pub/SubAsynchronous communication between microservices
LeaderboardSorted sets for real-time rankings

Connection with IConnectionMultiplexer

using StackExchange.Redis;

// IConnectionMultiplexer must be a SINGLETON (reuse TCP connections)
// Program.cs
builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
    ConnectionMultiplexer.Connect(new ConfigurationOptions
    {
        EndPoints   = { "my-cache.redis.cache.windows.net:6380" },
        Password    = builder.Configuration["Redis:Password"],
        Ssl         = true,
        AbortOnConnectFail = false,  // attempt to reconnect if connection fails
        ConnectRetry = 3
    }));

// In the service
public class CacheService
{
    private readonly IDatabase _db;
    public CacheService(IConnectionMultiplexer mux) => _db = mux.GetDatabase();
}

String operations

// SET with expiration (TTL)
await _db.StringSetAsync("user:42:name", "Alice Johnson", TimeSpan.FromMinutes(30));

// GET
RedisValue value = await _db.StringGetAsync("user:42:name");
if (value.HasValue)
    Console.WriteLine(value.ToString());

// INCR (atomic counter)
long visitCount = await _db.StringIncrementAsync("stats:page:home");

// SETNX (Set if Not eXists) — basis of distributed lock
bool locked = await _db.StringSetAsync(
    "lock:critical-resource",
    Environment.MachineName,
    TimeSpan.FromSeconds(30),
    When.NotExists);           // ← SETNX

Hash operations (structured object)

// Store an object as a hash
await _db.HashSetAsync("product:p-001", new HashEntry[]
{
    new HashEntry("name",  "Mechanical keyboard"),
    new HashEntry("price", "149.99"),
    new HashEntry("stock", "42")
});

// Read a field
string name = await _db.HashGetAsync("product:p-001", "name");

// Read the entire hash
HashEntry[] fields = await _db.HashGetAllAsync("product:p-001");

// Increment a numeric field in a hash
await _db.HashIncrementAsync("product:p-001", "stock", -1);

Lists, Sets and Sorted Sets

// ── List (FIFO queue / stack) ────────────────────────────────
await _db.ListRightPushAsync("queue:emails", "email-001");   // enqueue
RedisValue email = await _db.ListLeftPopAsync("queue:emails"); // dequeue

// ── Set (unordered set, unique values) ─────────────
await _db.SetAddAsync("tags:article:123", new RedisValue[] { "azure", "cloud", "storage" });
bool contains = await _db.SetContainsAsync("tags:article:123", "azure");
RedisValue[] tags = await _db.SetMembersAsync("tags:article:123");

// ── Sorted Set (leaderboard / ranking) ───────────────────────
// ZADD score member
await _db.SortedSetAddAsync("leaderboard:game", new SortedSetEntry[]
{
    new SortedSetEntry("Alice", 1500),
    new SortedSetEntry("Bob",   1200),
    new SortedSetEntry("Carol", 1800)
});
// Top 3
SortedSetEntry[] top3 = await _db.SortedSetRangeByRankWithScoresAsync(
    "leaderboard:game", 0, 2, Order.Descending);

Expiration and eviction

// Set/modify TTL of an existing key
await _db.KeyExpireAsync("session:user-42", TimeSpan.FromHours(2));

// Check remaining TTL
TimeSpan? ttl = await _db.KeyTimeToLiveAsync("session:user-42");

// Delete a key
await _db.KeyDeleteAsync("session:user-42");

Pub/Sub

// Publisher
var pub = mux.GetSubscriber();
await pub.PublishAsync("channel:orders", JsonSerializer.Serialize(order));

// Subscriber — in another service/instance
var sub = mux.GetSubscriber();
await sub.SubscribeAsync("channel:orders", (channel, message) =>
{
    var cmd = JsonSerializer.Deserialize<Order>(message);
    Console.WriteLine($"New order received: {cmd.Id}");
});

Distributed Lock with SETNX

// Pattern: acquire → try → finally release
string lockKey   = "lock:batch-processing";
string lockValue = Guid.NewGuid().ToString(); // unique value per instance
TimeSpan ttl     = TimeSpan.FromSeconds(30);

bool acquired = await _db.StringSetAsync(lockKey, lockValue, ttl, When.NotExists);
if (acquired)
{
    try
    {
        // Critical section — only one instance can enter here
        await ProcessBatchAsync();
    }
    finally
    {
        // Release only if the key still belongs to this instance (Lua script)
        const string releaseLua = @"
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('del', KEYS[1])
            else return 0 end";
        await _db.ScriptEvaluateAsync(releaseLua,
            new RedisKey[] { lockKey },
            new RedisValue[] { lockValue });
    }
}

Module 10 – Azure SQL Database SDK

SqlConnection / SqlCommand / SqlDataReader (ADO.NET)

using Microsoft.Data.SqlClient;

string connStr = builder.Configuration.GetConnectionString("AzureSQL");

// Parameterized query (NEVER concatenate → SQL injection)
await using var connection = new SqlConnection(connStr);
await connection.OpenAsync();

var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT Id, Name, Email FROM Customers WHERE Region = @region";
cmd.Parameters.AddWithValue("@region", "WA");

await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
    int    id    = reader.GetInt32(0);
    string name  = reader.GetString(1);
    string email = reader.GetString(2);
    Console.WriteLine($"{id} | {name} | {email}");
}

// INSERT with ExecuteNonQueryAsync
var insert = connection.CreateCommand();
insert.CommandText = "INSERT INTO Customers (Name, Email, Region) VALUES (@name, @email, @region)";
insert.Parameters.AddWithValue("@name",   "Acme Corp");
insert.Parameters.AddWithValue("@email",  "info@acme.com");
insert.Parameters.AddWithValue("@region", "WA");
int rowsAffected = await insert.ExecuteNonQueryAsync();

// Scalar
var scalar = connection.CreateCommand();
scalar.CommandText = "SELECT COUNT(*) FROM Customers WHERE Region = @r";
scalar.Parameters.AddWithValue("@r", "WA");
int total = (int)(await scalar.ExecuteScalarAsync())!;

Entity Framework Core with Azure SQL

// AppDbContext
public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
    public DbSet<Customer> Customers { get; set; }
    public DbSet<Order> Orders { get; set; }

    protected override void OnModelCreating(ModelBuilder mb)
    {
        mb.Entity<Customer>().HasIndex(c => c.Email).IsUnique();
        mb.Entity<Order>()
            .HasOne(o => o.Customer)
            .WithMany(c => c.Orders)
            .HasForeignKey(o => o.CustomerId);
    }
}

// Program.cs — registration with resilience retry
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("AzureSQL"),
        sqlOptions => sqlOptions.EnableRetryOnFailure(
            maxRetryCount:       5,
            maxRetryDelay:       TimeSpan.FromSeconds(30),
            errorNumbersToAdd:   null)));   // null = default transient strategy

// Usage in a service
public class CustomerService(AppDbContext db)
{
    public async Task<List<Customer>> GetByRegionAsync(string region) =>
        await db.Customers
            .Where(c => c.Region == region)
            .OrderBy(c => c.Name)
            .AsNoTracking()   // read-only → no tracking → more performant
            .ToListAsync();

    public async Task AddAsync(Customer customer)
    {
        db.Customers.Add(customer);
        await db.SaveChangesAsync();
    }
}

Retry Logic and connection resilience

Azure SQL can return transient errors (network, failover, throttling). EF Core and ADO.NET support retry strategies:

// ADO.NET — manual retry with Polly
using Polly;
using Polly.Retry;

AsyncRetryPolicy retryPolicy = Policy
    .Handle<SqlException>(ex => IsTransient(ex))
    .WaitAndRetryAsync(
        retryCount: 3,
        sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)),
        onRetry: (ex, delay, attempt, _) =>
            Console.WriteLine($"Attempt {attempt} after {delay}: {ex.Message}"));

await retryPolicy.ExecuteAsync(async () =>
{
    await using var conn = new SqlConnection(connStr);
    await conn.OpenAsync();
    // ... SQL commands ...
});

// Detection of transient Azure SQL errors
static bool IsTransient(SqlException ex) =>
    ex.Number is 4060 or 40197 or 40501 or 40613 or 49918 or 49919 or 49920 or 1205;

Connection Pooling

Azure SQL (and SQL Server) uses connection pooling by default with ADO.NET:

Connection string parameterDefault valueRole
Min Pool Size0Minimum maintained connections
Max Pool Size100Max simultaneous connections in the pool
Connect Timeout30 sDelay before error if no connection available
PoolingtrueEnable/disable pooling
// Optimized connection string for Azure SQL
Server=tcp:<server>.database.windows.net,1433;
Initial Catalog=<db>;
Encrypt=True;
TrustServerCertificate=False;
Connection Timeout=30;
Min Pool Size=5;
Max Pool Size=50;
Authentication=Active Directory Default;  // ← Entra ID (Managed Identity)

Authentication with Managed Identity (without password)

// EF Core with Entra ID / Managed Identity (no password in config)
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("AzureSQL"),
        sqlOptions =>
        {
            sqlOptions.EnableRetryOnFailure(5);
        })
    // Inject an Entra ID token on each connection
    .AddInterceptors(new AadAuthenticationDbConnectionInterceptor()));

// Custom interceptor
public class AadAuthenticationDbConnectionInterceptor : DbConnectionInterceptor
{
    private static readonly TokenCredential _credential = new DefaultAzureCredential();

    public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
        DbConnection connection, ConnectionEventData eventData,
        InterceptionResult result, CancellationToken ct)
    {
        var sqlConn = (SqlConnection)connection;
        var token = await _credential.GetTokenAsync(
            new TokenRequestContext(new[] { "https://database.windows.net/.default" }), ct);
        sqlConn.AccessToken = token.Token;
        return result;
    }
}

Module 11 – Blob Storage Lifecycle Management (JSON and advanced rules)

JSON structure of a lifecycle policy

{
  "rules": [
    {
      "name": "tierTransition",
      "enabled": true,
      "type": "Lifecycle",
      "definition": {
        "actions": {
          "baseBlob": {
            "tierToCool": {
              "daysAfterModificationGreaterThan": 30
            },
            "tierToCold": {
              "daysAfterModificationGreaterThan": 60
            },
            "tierToArchive": {
              "daysAfterLastAccessTimeGreaterThan": 90
            },
            "delete": {
              "daysAfterModificationGreaterThan": 365
            }
          },
          "snapshot": {
            "delete": {
              "daysAfterCreationGreaterThan": 90
            }
          },
          "version": {
            "tierToArchive": {
              "daysAfterCreationGreaterThan": 30
            },
            "delete": {
              "daysAfterCreationGreaterThan": 365
            }
          }
        },
        "filters": {
          "blobTypes": ["blockBlob"],
          "prefixMatch": ["logs/", "backups/2023"],
          "blobIndexMatch": [
            {
              "name": "project",
              "op": "==",
              "value": "migration-2023"
            }
          ]
        }
      }
    }
  ]
}

Tier transitions and available conditions

graph LR
    Hot -->|daysAfterModificationGreaterThan ≥ 30| Cool
    Cool -->|daysAfterModificationGreaterThan ≥ 60| Cold
    Cold -->|daysAfterLastAccessTimeGreaterThan ≥ 90| Archive
    Archive -->|manual rehydration| Hot
    Archive -->|manual rehydration| Cool
    Hot -->|delete| Deleted
    Cool -->|delete| Deleted
    Cold -->|delete| Deleted
    Archive -->|delete| Deleted
ConditionApplies toDescription
daysAfterModificationGreaterThanbaseBlob, snapshot, versionDays since last modification
daysAfterLastAccessTimeGreaterThanbaseBlobDays since last access (requires Last Access Time Tracking)
daysAfterCreationGreaterThansnapshot, versionDays since creation
daysAfterLastTierChangeGreaterThanbaseBlobDays since last tier change
prefixMatchfiltersBlob name prefix (container + path)
blobIndexMatchfiltersBlob index tag (key + operator + value)
blobTypesfiltersblockBlob and/or appendBlob

Deploy a policy via Azure CLI

# Apply the policy from a JSON file
az storage account management-policy create \
  --account-name <account> \
  --resource-group <rg> \
  --policy @lifecycle-policy.json

# Display the current policy
az storage account management-policy show \
  --account-name <account> \
  --resource-group <rg>

Rehydration from Archive

// Rehydrate a blob from Archive (can take 1 to 15 hours)
// Priority: High (~1h) or Standard (~15h)
var blobClient = containerClient.GetBlobClient("backup-2023.zip");
await blobClient.SetAccessTierAsync(
    AccessTier.Cool,
    rehydratePriority: RehydratePriority.High);

// Check the rehydration status
var props = await blobClient.GetPropertiesAsync();
Console.WriteLine($"Tier: {props.Value.AccessTier}");
Console.WriteLine($"Archive status: {props.Value.ArchiveStatus}");
// ArchiveStatus: "rehydrate-pending-to-hot" or "rehydrate-pending-to-cool"

Module 12 – Azure CDN (Content Delivery Network)

CDN Architecture

graph LR
    User[User\nSeattle] -->|1. Request| POP[Point of Presence\nSeattle CDN Edge]
    POP -->|Cache HIT| User
    POP -->|2. Cache MISS: origin| Origin[Origin\nBlob Storage / App Service]
    Origin -->|3. Content| POP
    POP -->|4. Cached + delivered| User
  • CDN Profile: Azure resource that groups CDN endpoints.
  • CDN Endpoint: public CDN URL (<name>.azureedge.net) + cache and origin configuration.
  • Origin: content source (Blob Storage, App Service, custom IP/FQDN).
  • POP (Point of Presence): geographically distributed edge nodes that cache content.

CDN providers available on Azure

ProviderTierKey features
Azure CDN Standard from MicrosoftStandardDelivery rules, compression, HTTPS, custom domain
Azure CDN Standard from EdgioStandardAdvanced rules, cache control
Azure CDN Premium from EdgioPremiumAdvanced rules, analytics, optimization

Create a profile and endpoint (Azure CLI)

# Create the CDN profile
az cdn profile create \
  --name cdn-myapp-prod \
  --resource-group rg-myapp \
  --sku Standard_Microsoft

# Create an endpoint pointing to Blob Storage
az cdn endpoint create \
  --name myapp-assets \
  --profile-name cdn-myapp-prod \
  --resource-group rg-myapp \
  --origin <account>.blob.core.windows.net \
  --origin-host-header <account>.blob.core.windows.net \
  --enable-compression true \
  --content-types-to-compress "text/css" "application/javascript" "image/svg+xml"

Caching rules

BehaviorDescription
Honor origin (default)Respects Cache-Control and Expires headers from the origin
OverrideReplaces origin headers with a specific duration
BypassNever uses cache for this content
Set if missingApplies a duration only if the origin sends no cache header

Query String Caching

ModeBehavior
IgnoreQueryStringimage.png?v=1 and image.png?v=2 → same cache entry
UseQueryStringEach query string combination = separate cache entry
BypassCachingRequests with query strings are never cached

Purge (cache invalidation)

# Purge a specific path
az cdn endpoint purge \
  --name myapp-assets \
  --profile-name cdn-myapp-prod \
  --resource-group rg-myapp \
  --content-paths "/images/logo.png" "/css/styles.css"

# Purge the entire cache (wildcard)
az cdn endpoint purge \
  --name myapp-assets \
  --profile-name cdn-myapp-prod \
  --resource-group rg-myapp \
  --content-paths "/*"
// Purge via C# SDK
using Azure.ResourceManager.Cdn;

var endpoint = await cdnClient.GetCdnEndpointAsync(endpointName);
await endpoint.Value.PurgeContentAsync(WaitUntil.Completed,
    new PurgeParameters(new[] { "/images/*", "/css/styles.css" }));

Custom domain + HTTPS

# 1. Create a CNAME DNS: mysite.com → myapp-assets.azureedge.net

# 2. Add the custom domain to the endpoint
az cdn custom-domain create \
  --endpoint-name myapp-assets \
  --profile-name cdn-myapp-prod \
  --resource-group rg-myapp \
  --name mysite \
  --hostname "cdn.mysite.com"

# 3. Enable HTTPS (certificate managed by Azure — free)
az cdn custom-domain enable-https \
  --endpoint-name myapp-assets \
  --profile-name cdn-myapp-prod \
  --resource-group rg-myapp \
  --name mysite

Delivery Rules — examples

{
  "name": "RedirectHTTPtoHTTPS",
  "order": 1,
  "conditions": [
    { "name": "RequestScheme", "parameters": { "matchValues": ["HTTP"] } }
  ],
  "actions": [
    { "name": "UrlRedirect", "parameters": { "redirectType": "PermanentRedirect", "destinationProtocol": "Https" } }
  ]
}

Module 13 – Static Website Hosting

Enable static website hosting

# Enable via Azure CLI
az storage blob service-properties update \
  --account-name <account> \
  --static-website \
  --index-document "index.html" \
  --404-document "404.html"

# Check the static website URL
az storage account show \
  --name <account> \
  --query "primaryEndpoints.web" \
  --output tsv
# → https://<account>.z9.web.core.windows.net/

Enabling automatically creates a special container $web in the storage account. Files uploaded to $web are served as a website.

// Upload build files to $web (e.g.: React/Angular/Vue)
var containerClient = serviceClient.GetBlobContainerClient("$web");

foreach (var file in Directory.GetFiles("./dist", "*", SearchOption.AllDirectories))
{
    string blobName = file.Replace("./dist/", "").Replace("\\", "/");
    var blobClient = containerClient.GetBlobClient(blobName);

    string contentType = Path.GetExtension(file) switch
    {
        ".html" => "text/html",
        ".css"  => "text/css",
        ".js"   => "application/javascript",
        ".png"  => "image/png",
        ".svg"  => "image/svg+xml",
        ".json" => "application/json",
        _       => "application/octet-stream"
    };

    await blobClient.UploadAsync(File.OpenRead(file), new BlobUploadOptions
    {
        HttpHeaders = new BlobHttpHeaders { ContentType = contentType }
    }, overwrite: true);
}

URL structure and endpoints

EndpointFormatUsage
Web endpoint (static website)https://<account>.z<N>.web.core.windows.net/Serves index.html at root, custom 404
Blob endpointhttps://<account>.blob.core.windows.net/$web/Direct blob access (doesn’t serve index by default)
CDN endpointhttps://<name>.azureedge.net/After CDN integration

CDN integration for a static website

graph LR
    User[User] --> CDN[Azure CDN\nazureedge.net]
    CDN -->|Cache MISS| StaticWeb[Static Website\n.z9.web.core.windows.net]
    StaticWeb --> BlobStorage[Container $web\nBlob Storage]
# Origin = the web endpoint (NOT the blob endpoint)
az cdn endpoint create \
  --name mysite-cdn \
  --profile-name cdn-mysite \
  --resource-group rg-mysite \
  --origin <account>.z9.web.core.windows.net \
  --origin-host-header <account>.z9.web.core.windows.net

Important: use the web endpoint (.z9.web.core.windows.net) as CDN origin, not the blob endpoint. This allows the CDN to correctly handle index.html and 404 documents.

Custom domain for static website

# Option 1: Via CDN (recommended — native HTTPS support)
# 1. Create CNAME: www.mysite.com → mysite-cdn.azureedge.net
# 2. Add custom domain on the CDN endpoint
# 3. Enable Azure-managed HTTPS

# Option 2: Directly on the storage account (HTTP only)
az storage account update \
  --name <account> \
  --resource-group rg-mysite \
  --custom-domain "www.mysite.com" \
  --use-subdomain false

SPA Routing Workaround

Single Page Applications (React, Angular, Vue) handle routing client-side. The problem: if a user directly accesses https://mysite.com/products/42, the server looks for a products/42/index.html file that doesn’t exist → 404.

Solution: configure the 404 document = index.html to return the app even on unknown routes.

# SPA configuration: 404 points to index.html
az storage blob service-properties update \
  --account-name <account> \
  --static-website \
  --index-document "index.html" \
  --404-document "index.html"   # ← SPA workaround
sequenceDiagram
    participant U as User
    participant CDN
    participant Storage as Blob Storage ($web)
    U->>CDN: GET /products/42
    CDN->>Storage: /products/42 (not found)
    Storage-->>CDN: 404 → index.html
    CDN-->>U: index.html (200)
    Note over U: React Router handles\n/products/42 client-side

Exam note: The HTTP code returned will be 404 but with the content of index.html. Some CDNs can be configured to return a 200 with the same content — check per provider.


Module 14 – AZ-204 Review Questions

Q1 — Blob Storage SDK

Your application needs to read a file report.pdf in the documents container of a storage account. What is the correct class sequence?

  • A) BlobServiceClientBlobClientBlobContainerClient
  • B) BlobContainerClientBlobServiceClientBlobClient
  • C) BlobServiceClientBlobContainerClientBlobClient
  • D) BlobClientBlobContainerClientBlobServiceClient

Answer: C — The hierarchy is always Service → Container → Blob.


Q2 — User Delegation SAS vs Service SAS

Which of the following statements is TRUE about User Delegation SAS?

  • A) It is signed by the storage account key.
  • B) It can be revoked without having to change the account key. ✅
  • C) It requires the Storage Blob Data Owner role to be generated.
  • D) It only allows read access.

Answer: B — The User Delegation SAS is signed by a User Delegation Key obtained via Entra ID. Revoking this key invalidates all associated SAS tokens, without touching the account key.


Q3 — Azure Table Storage

You need to store millions of application logs, each log having an appId and a timestamp. What PartitionKey/RowKey combination is most suitable?

  • A) PartitionKey = "logs", RowKey = random GUID
  • B) PartitionKey = appId, RowKey = inverted timestamp
  • C) PartitionKey = timestamp, RowKey = appId
  • D) PartitionKey = random GUID, RowKey = appId

Answer: BPartitionKey = appId allows retrieving all logs from an application in a single partition (efficient query). RowKey = inverted timestamp sorts logs from most recent to oldest.


Q4 — Cosmos DB Singleton

Why should CosmosClient be instantiated as a Singleton in an ASP.NET Core application?

  • A) Because Cosmos DB does not support multiple simultaneous connections.
  • B) To avoid data duplication in multiple containers.
  • C) Because it maintains persistent TCP connections and is expensive to create. ✅
  • D) To share the same RU/s budget across all instances.

Answer: CCosmosClient establishes persistent TCP connections at startup. Creating multiple instances leads to resource overconsumption and may exhaust available connections.


Q5 — Point Read vs Query

You are retrieving a Cosmos DB document for which you know the id ("prod-42") and the PartitionKey ("electronics"). What method should you use?

  • A) GetItemQueryIterator<T> with SELECT * FROM c WHERE c.id = 'prod-42'
  • B) ReadItemAsync<T>("prod-42", new PartitionKey("electronics"))
  • C) UpsertItemAsync<T> then check the result
  • D) ReplaceItemAsync<T> with the last known version

Answer: B — A point read is the most performant operation (~1 RU for 1 KB) because it directly accesses the item without scanning.


Q6 — Change Feed

Your Cosmos DB container holds orders. You want to update a microservice every time an order is deleted. How should you proceed?

  • A) Use the Change Feed in Latest Version mode — it captures deletions by default.
  • B) Implement soft delete: add a deleted = true field + TTL, and process this change via Change Feed. ✅
  • C) Use an Azure SQL trigger that synchronizes Cosmos DB.
  • D) Query the container every 30 seconds with a last-modification timestamp.

Answer: B — By default, the Change Feed does not capture deletions. The recommended practice is soft delete (mark as deleted + TTL). The All Versions and Deletes mode (preview) can also be used.


Q7 — Redis Distributed Lock

Your Azure application has multiple instances. You need to guarantee that only one batch processing runs at a time. What Redis command should you use?

  • A) StringSetAsync(key, value) without condition
  • B) ListRightPushAsync(key, value) then ListLeftPopAsync
  • C) StringSetAsync(key, value, ttl, When.NotExists)
  • D) HashSetAsync(key, field, value)

Answer: CSETNX (SET if Not eXists) is the basis of the Redis distributed lock. If the key already exists, the operation fails atomically, guaranteeing that only one instance acquires the lock.


Q8 — Azure SQL Retry

Your Azure application receives SqlException number 40613 errors when connecting to Azure SQL. What does this error represent and how should you handle it?

  • A) SQL syntax error — fix the query.
  • B) DTU quota exceeded — increase the service tier.
  • C) Transient error (database temporarily unavailable) — implement a retry strategy with exponential backoff. ✅
  • D) Permission error — add the db_datareader role.

Answer: C — Error 40613 means the database is temporarily unavailable (failover, maintenance). It is transient and must be handled with retry + backoff. EF Core with EnableRetryOnFailure() handles it automatically.


Q9 — Lifecycle Management

A lifecycle policy is configured with "daysAfterLastAccessTimeGreaterThan": 90 to move blobs to Archive. The rule does not seem to apply. What is the probable cause?

  • A) Blobs are of type appendBlob — lifecycle applies only to blockBlob.
  • B) Last Access Time Tracking is not enabled on the storage account. ✅
  • C) The condition cannot be used in a lifecycle policy.
  • D) Blobs are in the Cold tier — direct Cold → Archive transition is not supported.

Answer: B — The daysAfterLastAccessTimeGreaterThan condition requires Last Access Time Tracking to be enabled in the storage account settings. Without this, Azure does not know the last access date.


Q10 — CDN Query String

Your CDN endpoint is configured with IgnoreQueryString. Your team deploys a new version of the app.js file and adds ?v=2 to the URL. Users still receive the old version. Why?

  • A) CDN does not support JavaScript files.
  • B) IgnoreQueryString treats app.js?v=1 and app.js?v=2 as the same cache entry. ✅
  • C) Compression must be enabled to invalidate the cache.
  • D) The custom domain has not yet propagated in DNS.

Answer: B — With IgnoreQueryString, query strings are ignored for caching. app.js?v=1 and app.js?v=2 point to the same cache entry. Solution: switch to UseQueryString mode or perform a manual purge.


Q11 — Static Website SPA Routing

Your React application is hosted in Azure Static Website. Users report a 404 error when directly accessing https://myapp.com/dashboard. What will resolve this problem?

  • A) Manually create a dashboard/index.html file in $web.
  • B) Configure the --404-document to index.html. ✅
  • C) Enable CDN with BypassCaching mode.
  • D) Change the blob type from blockBlob to appendBlob.

Answer: B — The SPA routing workaround is to point the 404 document to index.html. When an unknown route is requested, Azure Storage returns index.html which allows React Router to handle client-side navigation.


Q12 — Cosmos DB Partition Key

You are designing a Cosmos DB container to store chat messages. The application has 50,000 active users sending messages. What is the best PartitionKey?

  • A) messageType (e.g.: “text”, “image”, “video”) — 3 distinct values
  • B) timestamp of the message send time
  • C) conversationId (unique identifier of each conversation) ✅
  • D) "messages" (constant value to group all messages)

Answer: CconversationId offers high cardinality and distributes data evenly. Queries to “retrieve all messages from a conversation” remain within a single partition (single-partition query). Options A and D create hot partitions. Option B creates very high cardinality but queries by conversation become cross-partition.


Visual summary — Azure storage architecture

graph TD
    subgraph GPv2 Storage Account
        BS[Blob Storage\nBlock / Append / Page Blobs]
        TS[Table Storage\nPartitionKey + RowKey]
        QS[Queue Storage]
        FS[File Storage]
    end

    subgraph Cosmos DB
        NoSQL[NoSQL API\nJSON Documents]
        Mongo[MongoDB API]
        Graph[Gremlin API]
    end

    subgraph Cache
        Redis[Azure Cache\nfor Redis]
    end

    subgraph Relational database
        SQL[Azure SQL Database\nADO.NET / EF Core]
    end

    subgraph Distribution
        CDN[Azure CDN\nDistributed POPs]
        SW[Static Website\n$web container]
    end

    App[Application\nASP.NET Core] --> BS
    App --> TS
    App --> NoSQL
    App --> Redis
    App --> SQL
    CDN --> BS
    CDN --> SW
    SW --> BS
ServiceMain SDKRecommended singleton class
Azure Blob StorageAzure.Storage.BlobsBlobServiceClient
Azure Table StorageAzure.Data.TablesTableServiceClient
Azure Cosmos DBMicrosoft.Azure.CosmosCosmosClient
Azure Cache for RedisStackExchange.RedisIConnectionMultiplexer
Azure SQL DatabaseMicrosoft.Data.SqlClient + EF CoreDbContext via DI

Search Terms

az-204 · cloud · storage · databases · azure · developer · microsoft · blob · cosmos · sdk · change · feed · cdn · lifecycle · static · website · partition · policy · architecture · available · connection · container · management · operations

Interested in this course?

Contact us to book it or get a custom training plan for your team.