This owns the deployment sequence, and the infrastructure handed to you at the start of it. It does not create the database, it does not own the identity, and it is not the pipeline.
Verified 2026-09-03 against the Azure Developer CLI 1.32.0, Azure CLI 2.90.0, sqlcmd 1.10.0 and .NET 8.0.421, by listing the template gallery and reading the shipped Bicep of every Microsoft-published template it returns for Azure SQL Database.
The correction: a first-party template's infrastructure is not a reviewed baseline
An agent treats a Microsoft sample's infra directory as settled, spends its attention on the
application code, and asked to reach Azure SQL Database without a password recommends the
first-party template as the way to get there. That inference is wrong in two separate ways at
once, and every claim below is settled by a command rather than by reading.
Ask the gallery what exists, do not extrapolate from the family
azd version
azd template list --output json | grep -c '"Azure-Samples/todo-'
Measured 2026-09-18: 303 templates, zero todo- entries. The family was archived 2026-09-04 and
left the gallery 2026-09-08, yet azd init -t todo-csharp-sql still initializes it and Microsoft
Learn still lists it, so users still arrive with it. todo-csharp-cosmos-sql is Cosmos DB for
NoSQL. todo-nodejs-sql and todo-python-sql never existed.
The archived web application blueprint for Azure SQL Database uses a password
Read its infrastructure without cloning it:
curl -s https://raw.githubusercontent.com/Azure-Samples/todo-csharp-sql/main/infra/app/db-avm.bicep \
| grep -n -i "administratorLogin\|create user\|db_owner\|external provider"
Measured 2026-09-03: administratorLogin, create user ${APPUSERNAME} with password,
alter role db_owner add member, and zero hits for external provider.
| It does | Not |
|---|---|
Provisions the logical server with an administratorLogin and a password |
Microsoft Entra-only authentication |
| Creates the application's database user with a password, from a deployment script | CREATE USER ... FROM EXTERNAL PROVIDER |
Adds that user to db_owner |
Any narrower role |
| Stores the connection string, user name and password included, in a vault | A passwordless connection string |
The template does create a managed identity, and that is what makes the misreading survive a glance. It is system-assigned to the API and it reads the vault. What the vault holds is a connection string with a password in it. "It uses managed identity" is true of the vault and false of the database. Converting the template is an infrastructure change, not an application one: the server's authentication mode, the deployment script, the role and the connection string all move together.
The firewall rule that survives choosing correctly
for r in todo-csharp-sql:app/db-avm.bicep \
functions-quickstart-dotnet-azd-sql:app/db.bicep \
functions-quickstart-python-azd-sql:app/db.bicep \
functions-quickstart-typescript-azd-sql:app/db.bicep; do
printf '%-42s ' "${r%%:*}"
curl -s "https://raw.githubusercontent.com/Azure-Samples/${r%%:*}/main/infra/${r##*:}" \
| grep -c "startIpAddress: '0.0.0.1'"
done
Four of four, measured 2026-09-03: 1 on every line. All four build the logical server from
the Azure Verified Module and all four ship a rule named Azure Services running 0.0.0.1 to
255.255.255.254, every address a client can present. Three are Entra-only with a managed
identity and are otherwise the ones to copy, so picking the better template fixes the password and
leaves the firewall open.
The name points at a different rule. The Azure CLI's own help for --start-ip-address says to
use 0.0.0.0 to represent all Azure-internal IP addresses, and Microsoft Learn records the Allow
Azure services special case as a rule whose start and end address are both 0.0.0.0. A rule from
0.0.0.1 to 255.255.255.254 is not a narrower version of it.
Say this to the user, then let them decide. These are Microsoft-published samples and are not ours to edit on someone's behalf. Name the rule out loud and offer to narrow it:
az sql server firewall-rule list -g <group> -s <server> -o table
az sql server firewall-rule delete -g <group> -s <server> -n "Azure Services"
# the documented Allow Azure services case, if Azure-internal traffic is what was meant
az sql server firewall-rule create -g <group> -s <server> -n AllowAzureServices \
--start-ip-address 0.0.0.0 --end-ip-address 0.0.0.0
# and the developer machine, if it connects directly
az sql server firewall-rule create -g <group> -s <server> -n dev-box \
--start-ip-address <your-ip> --end-ip-address <your-ip>
Narrow it after the first successful deployment, not before. The post-provision hook runs from
a machine that then needs a rule of its own, and without one it fails as a timeout rather than as a
permission error. The three Functions quickstarts ship infra/scripts/addclientip.ps1 for exactly
this. provision-azure-sql-db owns authoring firewall rules; this skill owns catching an inherited
one.
On any other template, run the same two greps before running anything
grep -ri "external provider" ./infra | wc -l # 0 means the database user is password-based
grep -rn "IpAddress" ./infra # read the range, not the rule's name
azd provision --preview # what would be created, before it is
Open references/azd-and-azure-sql.md before choosing a template or writing the hook: it carries the per-template evidence, which templates are Microsoft-published rather than community ones, and the hook wiring end to end.
What to copy instead. The three serverless quickstarts are the pattern worth taking: an
Entra-only server, the deploying user as administrator, a user-assigned managed identity for the
application, and the grant run from a postprovision hook against the user database. Their roles
are db_datareader, db_datawriter and db_ddladmin, still wider than most applications need;
replace them with a custom role containing only the permissions the application uses.
The seam in the middle, which the good templates exist to close
The deployment tool provisions Azure resources and deploys code. A database user is neither. The
application's identity means nothing to the database until somebody runs
CREATE USER ... FROM EXTERNAL PROVIDER and grants it roles, inside the database, from a connection
authenticated with Microsoft Entra ID. Until then the run goes green and the application fails at
its first query with a login failure that reads as a bad credential.
entra-id-auth owns that statement, its clauses and its error codes. Open it when you are about
to write the grant. This skill owns where the grant goes in the sequence and how it gets run.
The four commands, and what each one actually does
| Command | What it does | What it does not do |
|---|---|---|
init |
Sets up azure.yaml, an infra directory, an environment under .azure, from a template or from existing code |
Create anything in Azure |
provision |
Creates the Azure resources and writes the template's outputs into the environment | Deploy code. Create anything inside the database |
deploy |
Pushes built application code to resources that already exist | Create resources. A deploy before a provision has nothing to deploy to |
up |
Runs packaging, provisioning and deployment in one command | Anything the individual steps do not do. It is a convenience, not extra behaviour |
Keeping the two halves apart is how you debug them. Build the artifact yourself and hand it over:
dotnet publish ./src/api -c Release -o ./artifacts/api
azd deploy api --from-package ./artifacts/api
Do not depend on the internal ordering of up. azd up --help calls it package, provision and
deploy; the hooks reference on Microsoft Learn calls it restore, provision and deploy. What is
stable is that provisioning happens before deployment and that postprovision runs after resources
are created. Pin the order explicitly if the project needs it, in the shape the command's own help
gives:
# azure.yaml
workflows:
up:
- azd: provision
- azd: package --all
- azd: deploy --all
Step 1: know where the infrastructure is coming from
azure.yaml at the project root is the only required file, mapping each service to a host and a
source directory:
name: my-project
services:
api:
project: ./src/api
language: csharp
host: appservice
Provisioning reads Bicep from ./infra with main.bicep as the entry point, both overridable
under an infra key, path and module.
That Bicep has to come from somewhere, and there are only two real sources: a template, read first on the terms above, or hand-written from Azure Verified Modules. There is no third option, and this is where an agent invents one:
The compose feature cannot create an Azure SQL Database. Its database resource types are Azure Cosmos DB, Azure Cosmos DB for MongoDB, Azure Cosmos DB for PostgreSQL, Azure Cache for Redis and Azure Database for MySQL. There is no Azure SQL Database entry, so
azd addwill not produce one, andazd infra generateonly writes out what compose already knows.
Creating the server, the database and the firewall rule with the Azure CLI instead belongs to
provision-azure-sql-db.
Step 2: put the grant between provisioning and deployment
The database user has to exist after the identity and the database do, and before the application
serves its first request. There is exactly one place that is true, and it is the postprovision
hook, registered at the project root rather than inside a service:
# azure.yaml
hooks:
postprovision:
posix:
shell: sh
run: ./infra/scripts/configure-database.sh
continueOnError: false
interactive: false
windows:
shell: pwsh
run: ./infra/scripts/configure-database.ps1
continueOnError: false
interactive: false
Inside the script, read what provisioning wrote and authenticate as the identity that provisioned:
eval "$(azd env get-values | sed 's/^/export /')"
sqlcmd -S "$AZURE_SQL_SERVER_NAME" -d "$AZURE_SQL_DATABASE_NAME" \
--authentication-method ActiveDirectoryAzureDeveloperCli \
-i ./infra/scripts/grant-app-identity.sql
Develop that hook on its own, without reprovisioning:
azd hooks run postprovision
Four things make this work, and each fails quietly if it is missing.
- The infrastructure outputs the identity's name and principal id, because a hook is a shell script and can only see what provisioning wrote into the environment.
- The hook reads those outputs rather than guessing resource names.
continueOnErrorstays false. Setting it true turns the one step that silently matters into a step that silently does not happen.- Whoever runs the hook is administrator of the logical server, authenticated with Microsoft
Entra ID.
-Galone falls back toActiveDirectoryDefault, which can pick a different signed-in identity than the one that just provisioned; naming the method is what pins it.
Step 3: get the connection string to the application
An environment value is not an application setting. The value really is set and really does show up locally, which is why this reads as success.
| Where it lives | What reads it | |
|---|---|---|
| Environment value | .azure/<environment>/.env in the project |
Provisioning inputs, hooks, and the local command session |
| Application setting | The deployed application's configuration in Azure | The running application |
The infrastructure has to write it as an application setting, which in Bicep means passing it into the hosting module:
appSettings: {
AZURE_SQL_CONNECTION_STRING: 'Server=${sqlServer.outputs.fullyQualifiedDomainName}; Authentication=Active Directory Default; Database=${databaseName}; User Id=${apiIdentity.outputs.clientId}'
}
That is the shape Microsoft Learn gives for a function app reaching Azure SQL Database with a
managed identity. There is no password and no user name, which is the whole point. For a
user-assigned identity the client id has to be named, because the default credential otherwise
cannot choose between the identities attached to the host; for a system-assigned one, User Id is
omitted instead. The client id is not the principal id the grant uses.
Open connect-to-azure-sql when the driver is not Microsoft.Data.SqlClient: the keyword
spellings differ per driver and are not guessable.
Step 4: know what teardown does and does not remove
azd down # prompts for confirmation
azd down --purge # also permanently deletes resources that are soft-deleted by default
azd down --force --purge # no confirmation at all
It deletes the Azure resources for the environment, not local project files and not the
environment's stored values, so .azure/<environment>/.env survives holding outputs that now name
resources that no longer exist. The database goes with everything else and its data is not
backed up by this command. A database the project did not create is not deleted, and the
database user created for the application identity survives inside it, pointing at a principal that
no longer exists.
Check it worked
A green run is not the check. Four things, in this order, and the last one is the only one that settles it.
azd env get-values | grep -i sql # provisioning wrote the server and database names
azd show # the resources azd believes it owns
az sql server ad-admin list -g <group> -s <server> -o table
az sql server firewall-rule list -g <group> -s <server> -o table
az webapp config appsettings list -g <group> -n <app-name> \
--query "[?name=='AZURE_SQL_CONNECTION_STRING'].name" -o tsv
Expect a server and database name in the environment, a named Entra administrator, no rule wider than you chose, and the setting present on the deployed application. An empty result from the last command is the Step 3 failure: the value exists locally and never reached Azure.
Then confirm the database user exists, connected to the user database and not master:
SELECT p.name, p.authentication_type_desc, r.name AS role_name
FROM sys.database_principals AS p
LEFT JOIN sys.database_role_members AS m ON m.member_principal_id = p.principal_id
LEFT JOIN sys.database_principals AS r ON r.principal_id = m.role_principal_id
WHERE p.name = '<identity-name>';
Expect at least one row, authentication_type_desc reading EXTERNAL, and named roles that are not
db_owner. Zero rows is the seam: the grant did not run, or it ran against master. Finish by
calling an application endpoint that reads the database, because only the application proves its own
identity works.
Under an agent, prompts are failures
azd documents --no-prompt on every command as automatically enabled when it detects a CI/CD or
AI-agent environment, failing if any required value cannot be resolved (AZD_NON_INTERACTIVE=false
opts out). So under an agent, any value that would have been prompted for is a hard failure. Set
them before running anything:
azd env new <environment-name>
azd env set AZURE_SUBSCRIPTION_ID <subscription-id>
azd env set AZURE_LOCATION <region>
Hooks are the other half, and the two Learn pages disagree: the hooks reference says hooks run in
interactive mode by default, the schema reference says interactive defaults to false. Set it
explicitly on any hook that runs unattended rather than relying on either.
Where this stops
| The request | The owner |
|---|---|
| Create the server, database and firewall rule with the Azure CLI | provision-azure-sql-db |
| The database user, the grant, the driver keywords, the error codes | entra-id-auth |
| Retry, pooling and encryption doctrine | connect-to-azure-sql |
| A pipeline that deploys on push or on merge | github-actions-for-sql |
| Getting the schema into the database | schema-migrations-safely |
| What roles to leave the application identity holding | entra-id-auth, followed by a custom least-privilege role |
| Sequencing a whole new project, before any of this | Plan the application, data-access, schema, identity, and deployment choices together |
The boundary with the pipeline is a real one, not a filing decision. Deploying from a laptop
makes the developer administrator of the logical server, so the developer can run the grant.
Deploying from a pipeline makes the pipeline's identity administrator instead, and a developer then
cannot. azd pipeline config is the handoff point.
Do not
- Do not treat a first-party template's
infradirectory as reviewed, and do not call one passwordless because a managed identity appears in it. Check what that identity authenticates to. - Do not accept a firewall rule because its name says Azure services. Read the range, and expect the wide one even in the templates that get identity right.
- Do not extrapolate template names from the family, and do not report a deployment as working because the tool reported success.
- Do not put the grant in a hook that runs after deployment, and do not set
continueOnErrortrue on it. The application can serve a request before it runs. - Do not set a connection string as an environment value and expect the deployed application to read
it, and do not leave
TrustServerCertificateon in one copied out of a sample. - Do not assume a database, server or resource name in a hook. Read it from the environment values that provisioning wrote.
- Do not restate the
CREATE USERstatement, the role grants or the driver keyword spellings here.entra-id-authowns them, and two skills stating the same syntax is how they start disagreeing.
References
- references/azd-and-azure-sql.md: open it before choosing a template, before writing the post-provision hook, or when a claim above disagrees with what you are seeing. It carries the per-template evidence, which templates are Microsoft-published, the naming traps, and the hook wiring from infrastructure output to grant.
azd template listand the community gallery atazure.github.io/awesome-azdare the only current statement of what templates exist. List them rather than recalling them.- Hooks reference and azure.yaml schema, when a hook name or option is in question; environment variables when a value is not arriving where it was expected; network access controls when a firewall rule needs justifying.