7. Production
The difference between "I uploaded a file in the portal" and "I run the company's data on this." Five pillars, always the same five.
[Image Prompt: 2D minimalistic diagram of five production pillars, security, cost, scaling, observability, and reliability, arranged around a central Azure storage account resource, flat design, clean vector art style, white background]
Security
Storage is where the data is, which makes it the thing attackers actually want. The good news is that the whole posture reduces to six settings and one habit.
The six settings
| Setting | Value | Why |
|---|---|---|
allowSharedKeyAccess |
false |
The highest-value setting on the account. Kills the root credential path entirely and forces every caller through Entra ID + RBAC. Also the one that separates the control plane from the data plane for real |
allowBlobPublicAccess |
false |
Makes anonymous container access impossible by policy, not by hoping nobody sets a container to public. This is the setting that would have prevented most publicised "open bucket" incidents |
publicNetworkAccess |
Disabled in prod |
Combined with a private endpoint, removes the account from the internet entirely |
minimumTlsVersion |
TLS1_2 or higher |
Older versions are still accepted by default on older accounts |
supportsHttpsTrafficOnly |
true |
Default on new accounts; verify on inherited ones |
| Encryption | Platform-managed by default; customer-managed key in Key Vault where required | Data is always encrypted at rest. CMK gives you the revocation lever and the audit story a regulator asks about. Infrastructure encryption adds a second layer if you need defence in depth |
The habit: least privilege at container scope
The built-in data-plane roles:
| Role | Grants | Use for |
|---|---|---|
Storage Blob Data Reader |
Read and list blobs | Consumers, BI, read replicas of a pipeline |
Storage Blob Data Contributor |
Read, write, delete | Ingestion jobs, application write paths |
Storage Blob Data Owner |
The above + POSIX ACL management (HNS) | Data-lake administrators only |
Storage Blob Delegator |
Mint user-delegation SAS tokens | Grant alongside a data role, to services that hand out links |
And the ones that grant no data access: Owner, Contributor, Reader, Storage Account Contributor. They govern the resource. Note the asterisk: a Contributor can read the account keys, so
Contributor is effectively a data owner unless allowSharedKeyAccess = false. That's the whole
argument for that setting in one sentence.
When the built-in role is too broad — the common case is "read blobs but never delete them", which
Data Reader gives you and Data Contributor doesn't stop at. A custom role:
{
"Name": "Blob Writer No Delete",
"IsCustom": true,
"Description": "Read and write blobs, but never delete them.",
"Actions": [],
"NotActions": [],
"DataActions": [
"Microsoft.Storage/storageAccounts/blobServices/containers/blobs/read",
"Microsoft.Storage/storageAccounts/blobServices/containers/blobs/write",
"Microsoft.Storage/storageAccounts/blobServices/containers/blobs/add/action"
],
"NotDataActions": [
"Microsoft.Storage/storageAccounts/blobServices/containers/blobs/delete"
],
"AssignableScopes": ["/subscriptions/{sub}/resourceGroups/rg-data"]
}
Note DataActions rather than Actions — that's what makes it a data-plane role, and getting the two
sections mixed up produces a role that grants nothing and takes an afternoon to debug.
SAS discipline
If you must hand out links:
- Use a user-delegation SAS (
--as-user), signed via Entra ID, bounded by the signer's own permissions, and revocable by removing the role assignment. - Short expiry. Hours, not months. A one-year SAS is a credential.
- Narrowest permissions and scope — read-only, one blob, and an IP range if you can.
- Never a service SAS signed with the account key without a stored access policy, because the only way to revoke it otherwise is rotating the key, which breaks everything else at the same time.
- Set a SAS expiry policy on the account so over-long SAS tokens are flagged in Defender for Cloud and Azure Policy.
Network isolation
- Private endpoint — the account gets a private IP in your VNet. One per sub-resource (
blob,dfs, …). Requires theprivatelink.blob.core.windows.netPrivate DNS zone linked to every VNet that resolves it — including, easily forgotten, the one your on-prem DNS forwards from. - Service endpoints + VNet rules — cheaper and simpler, but traffic still uses the public endpoint (over the Azure backbone) and the account still has a public IP. Acceptable for internal-only workloads; a private endpoint is the stronger answer.
- Firewall IP rules — for the handful of cases where a fixed external IP needs access. Note the
bypass = AzureServicesexception, which lets first-party services like Backup, Monitor, and Event Grid reach the account; turning it off breaks things in non-obvious ways. - The trusted-services + resource-instance rule — the most precise option: allow a specific Azure resource (one Data Factory, one Function App) through the firewall by its resource ID, rather than all Azure services.
The gotchas specific to this service
- Anonymous public access on a container is the classic exposure. Kill it at the account level.
- A SAS token in a URL is in every log it passes through — client logs, proxy logs, browser history, the ticket someone pasted it into.
- Soft-deleted data is still data. A compliance deletion request isn't satisfied until the retention window expires or the version is purged.
- The
$logscontainer and static-website$webcontainer are easy to forget when auditing what's public. - Malware. Anything accepting third-party uploads should enable Microsoft Defender for Storage with malware scanning, and use a quarantine-then-promote pattern rather than writing straight into a container something else reads.
Cost
Blob Storage has five meters, and the cheap-looking option turns one of the others up.
| Meter | What drives it | The trap |
|---|---|---|
| Capacity | GB-month, at a rate that drops Hot → Cool → Cold → Archive | Includes soft-deleted blobs, old versions, snapshots, and uncommitted blocks — none of which appear in a container listing |
| Transactions | Per 10,000 operations, at a rate that rises Hot → Cool → Cold → Archive | Tiering frequently-read data to Cool makes it cost more. This inversion is the number-one tiering mistake |
| Data retrieval | Per GB read from Cool / Cold / Archive | Invisible until the first month's bill after a big tiering exercise |
| Egress | Per GB leaving the Azure region or Azure entirely | A CDN or Front Door in front is often cheaper than the egress it replaces |
| Early deletion | Deleting or re-tiering before the minimum retention period | Pro-rata charge for the remainder. A lifecycle rule that archives at 30 days and deletes at 90 is quietly paying this on every object |
The three biggest wins, in order
1. A lifecycle management policy. The single largest lever, and it costs you a JSON rule.
{
"rules": [
{
"enabled": true,
"name": "tier-raw-data",
"type": "Lifecycle",
"definition": {
"filters": {
"blobTypes": ["blockBlob"],
"prefixMatch": ["raw/"]
},
"actions": {
"baseBlob": {
"tierToCool": { "daysAfterModificationGreaterThan": 30 },
"tierToArchive": { "daysAfterModificationGreaterThan": 365 },
"delete": { "daysAfterModificationGreaterThan": 2555 }
},
"version": { "delete": { "daysAfterCreationGreaterThan": 90 } },
"snapshot": { "delete": { "daysAfterCreationGreaterThan": 90 } }
}
}
}
]
}
Two notes: rules evaluate on a schedule (roughly daily), not instantly; and
daysAfterLastAccessTimeGreaterThan is available if you enable last access time tracking — better
signal than modification time, at the cost of a small transaction overhead for the tracking itself.
2. Expire old versions. Versioning is the most common runaway cost in this topic because nothing in the portal makes the extra bytes obvious. Every account with versioning on needs a version-expiry rule, full stop.
3. Fewer, larger objects. Ten million 4 KB blobs cost far more in transactions than a hundred 400 MB parquet files holding the same bytes, and analytics engines read them faster too.
Secondary levers: reserved capacity (a one- or three-year commitment on stored capacity, for predictable data volumes), right-sizing redundancy (GZRS everywhere is a doubled bill for data that may only need ZRS — decide per dataset, which means per account), and killing orphaned uncommitted blocks from failed uploads.
What keeps billing when nothing is using it
Everything. Storage bills for bytes at rest regardless of access — which is a good property compared with always-on compute plans, but it means the only way the bill goes down is deleting or tiering data. "Nobody uses that container any more" is not a saving until someone acts on it. Set a monthly review, or better, a lifecycle rule that acts on last-access time.
Scaling and limits
The essential Azure-specific point: almost every limit that matters is counted per storage account, and a few are counted per subscription per region.
| Limit | Scope | Notes |
|---|---|---|
| Ingress / egress bandwidth | Per storage account | Varies by region, redundancy, and account type ⚠️ verify against current Azure docs |
| Request rate (IOPS) | Per storage account | Standard has a ceiling; Premium block blob is much higher ⚠️ verify |
| Throughput to a single blob | Per blob | Far below the account ceiling. One hot file cannot absorb an account's bandwidth |
| Storage accounts | Per subscription, per region | A soft limit, raisable via support ⚠️ verify. This is why "one subscription per environment" matters |
| Max block blob size | Per blob | Block size × block count; tens of TB in practice ⚠️ verify |
| Blob index tags per blob | Per blob | A small fixed number ⚠️ verify |
| Containers per account / blobs per container | Effectively unbounded | Constrained by capacity, not count |
Raising a limit. Soft limits go through a quota request — the Quotas blade in the portal, the
az quota commands where supported, or a support ticket. Do it before the launch, not during it; quota
increases are not instant and are not guaranteed.
Scaling strategy, in order of preference:
- Fix the key distribution. A monotonic prefix (timestamps, sequential IDs) concentrates load on one partition range. Hash or reverse the leading characters.
- Parallelise across blobs, not within one.
- Shard across accounts. The real answer past a single account's ceiling. Shard by tenant, dataset, or environment — and design the sharding key before you need it, because retrofitting it means a data migration.
- Premium block blob if the problem is request rate and latency, not bandwidth.
- CDN / Front Door for read-heavy public content — the cheapest request is the one storage never sees.
- Respect the retries. The SDKs already back off exponentially on
503 ServerBusy. Do not replace that with your own tight retry loop; you will turn a throttle into an outage.
Observability
Diagnostic settings are off by default. Until you turn them on, you have platform metrics and nothing else — no record of who read what, no per-operation latency, no way to answer "when did this blob get deleted, and by whom".
What to turn on
| Category | Type | Why |
|---|---|---|
StorageRead |
Log | Access audit, and the only way to see which blobs are actually hot |
StorageWrite |
Log | Ingestion audit |
StorageDelete |
Log | The one you'll want during an incident, and the one nobody enables beforehand |
Transaction |
Metric | Volume, latency, and response type — where throttling shows up |
Capacity |
Metric | Blob capacity, container count, blob count. Emitted at a low frequency (roughly daily) |
Route them to a Log Analytics workspace. The diagnostic setting must target
<accountId>/blobServices/default, not the account resource ID — pointing it at the account gets you
nothing and no error.
The metrics worth alerting on
Transactionssplit byResponseType, alerting onServerBusyErrorandServerTimeoutError— throttling, before users report slowness.Availabilitybelow 100% sustained.SuccessE2ELatency/SuccessServerLatency— the gap between them is your network and client, not Azure's service.BlobCapacitygrowth rate — a step change usually means a versioning or soft-delete rule that isn't expiring anything.- Egress — a spike is either a legitimate bulk read or an exfiltration, and both are worth knowing about within the hour.
The KQL query you'll actually run
// Who is calling this account, how, and how often — the first query in any investigation.
StorageBlobLogs
| where TimeGenerated > ago(24h)
| summarize
Calls = count(),
Failures = countif(StatusText !in ("Success", "SASSuccess")),
AvgLatency = avg(DurationMs)
by OperationName, AuthenticationType, CallerIpAddress, StatusText
| order by Calls desc
Two more worth keeping:
// Deletions in the last 7 days — the "what happened to that file" query.
StorageBlobLogs
| where TimeGenerated > ago(7d) and OperationName has "Delete"
| project TimeGenerated, Uri, AuthenticationType, RequesterObjectId, CallerIpAddress, StatusText
// Throttling over time — is this a spike or a ceiling?
StorageBlobLogs
| where TimeGenerated > ago(24h) and StatusText contains "ServerBusy"
| summarize Throttled = count() by bin(TimeGenerated, 5m)
| render timechart
The activity log covers the other half: control-plane changes such as firewall edits, key rotations, and redundancy changes, with the identity that made them. Between activity log (who changed the resource) and storage logs (who touched the data), you can answer almost any incident question — but only if both were on beforehand.
Reliability
Choosing redundancy honestly
| Setting | Survives | Doesn't survive | Pick it for |
|---|---|---|---|
| LRS | Disk, node, rack failure | Loss of a datacentre | Dev, and reproducible data you could regenerate |
| ZRS | Loss of an entire availability zone | Loss of the region | ✅ The production default in any zone-enabled region |
| GRS | Regional loss (with manual failover, non-zero RPO) | — | Data you must not lose, where hours of RTO is acceptable |
| GZRS | Zone loss with no action, plus regional loss with failover | — | Critical data. The default for anything regulated |
| RA-GRS / RA-GZRS | The above, plus immediate read access to the secondary | — | Read-heavy systems that can serve slightly stale data during a regional event |
Three things people get wrong:
- Redundancy is not backup. Every redundancy option faithfully replicates a deletion. Only soft delete, versioning, point-in-time restore, and Azure Backup protect against a mistake or a malicious actor.
- Geo-replication is asynchronous. Check the last sync time metric; everything after it is lost in an unplanned failover. If your RPO is zero, storage redundancy alone will not give it to you.
- Failover is account-wide, disruptive, and historically leaves the account as LRS afterwards, requiring a manual re-upgrade. Practise it in a non-production account so the runbook is real.
Backup and restore
Layer them; each covers a different failure:
| Protection | Covers | Must be enabled before |
|---|---|---|
| Soft delete (blob and container) | Accidental delete | The delete |
| Versioning | Accidental overwrite | The overwrite |
| Point-in-time restore | Bulk corruption across a container | The corruption — and it needs versioning + change feed + soft delete all on |
| Object replication | Regional copy under your control, per-container, with your own rules | The event |
| Azure Backup for blobs | Policy-driven, vaulted, with its own RBAC boundary | The event |
| Immutable storage (WORM) | Ransomware and insider deletion, plus compliance | The event, and a locked policy can't be shortened |
The failure drill
Run these on a schedule, in a non-production account, and write down the times:
- Restore a single blob from soft delete and from a version. Time it. Confirm who has permission — during the real incident it will be someone who has never done it.
- Point-in-time restore a container to a timestamp ten minutes ago. Confirm the prerequisites are actually enabled in prod, not just in the drill account.
- Initiate a customer-managed failover on a GRS test account. Observe the last-sync-time gap, the duration, and the post-failover redundancy state.
- Break a lease on a blob that a stuck process is holding — you will need this the first time a Terraform apply dies mid-run.
- Revoke access: remove a role assignment and confirm the caller loses access within the
propagation window; rotate a key and confirm nothing breaks (which, if you set
allowSharedKeyAccess = false, it can't).
Next: Interview Questions →
← Back to the Blob Storage overview · ← Previous: Integrations