microsoft/azuresql-devcontainers ships four dev container templates. Each starts two containers,
one for the application toolchain and one for a database, with a sample Library schema deployed
into it. The published walkthrough is a screenshot tour; what breaks is in the files. Not covered:
the SQL Database project format itself (sql-database-projects) and a cloud database
(provision-azure-sql-db).
The SQL Database project's target platform decides what Azure SQL Database will accept. The database in these templates is SQL Server, major version 17, which reports EngineEdition 3, not the Azure SQL Database engine.
Everything below was measured on 2026-09-17 against template version 2.0.0, applied from
ghcr.io/microsoft/azuresql-devcontainers/dotnet:2.0.0 and brought up with the dev container CLI
0.89.0 on Docker 29.4.0 and Compose v5.1.2, on an Apple silicon host. The fourteen probes behind
those claims were run again on 2026-09-18 against a fresh application of the same template and all
passed; 2.0.0 was still the newest release and the head of main that day. Version 2.0.0 replaced the
engine, the runtimes and the create-time script, so nothing here carries over from 1.2.2.
Which version is in front of you. The applied workspace does not record it: the template
tooling omits devcontainer-template.json, README.md and NOTES.md when it copies files in, so
there is no version string to read afterwards. Two files answer it instead:
grep -m1 image: .devcontainer/docker-compose.yml # 2.0.0 pins mcr.microsoft.com/mssql/server:2025-latest
grep -m1 FROM .devcontainer/Dockerfile # 2.0.0 pins a 2-10.0-noble, 5-24-trixie or 3-3.14-trixie base
A workspace whose database service still names mcr.microsoft.com/azure-sql-edge is 1.2.2 or
earlier, and none of the behaviour below applies to it. Re-apply the template rather than editing
the old files.
What decides whether your SQL is acceptable, and it is not the engine
This is the one thing to understand about these templates, and the one most likely to be got wrong. Two different things are in play.
-
The engine in the database container is SQL Server, not the Azure SQL Database engine. It will cheerfully run statements Azure SQL Database refuses.
-
The schema is validated against Azure SQL Database by the SQL Database project, through one setting.
database/Library/Library.sqlprojdeclares the target platform:<DSP>Microsoft.Data.Tools.Schema.Sql.SqlAzureV12DatabaseSchemaProvider</DSP>That provider is the Azure SQL Database one. It is what makes
dotnet build database/Librarya statement about the cloud rather than about the local engine, and changing it removes the only check in the workspace that looks at the platform you are deploying to.
The gate has teeth, and it was watched failing rather than assumed. A FILESTREAM column is
ordinary SQL Server and does not exist in Azure SQL Database. Added to the shipped project, the
build stops:
Build error SQL70015: Keyword or statement option 'FILESTREAM' is not supported for the targeted platform.
Build FAILED.
The same file, in a copy of the same project with only the provider changed to the SQL Server one,
Sql170DatabaseSchemaProvider, builds clean. Nothing about the SQL changed; only the platform it
was judged against. For completeness, the running engine parses the statement too and asks for a
filegroup, Msg 1969, which is a configuration answer rather than a refusal.
So the working rule in one line: put schema in the project and let the build judge it, and treat
anything you ran only in an ad hoc session as unproven for Azure SQL Database. A statement that
succeeds against the local engine proves that SQL Server accepts it and nothing more. sql-database-projects
owns the project itself, its publish options and what a dacpac carries.
What is actually running, as dated context
Read the image out of the compose file, then ask the server, and never infer the engine from a template named for Azure SQL Database:
docker compose -f .devcontainer/docker-compose.yml config | grep -E '^[[:space:]]+image:'
sqlcmd -S localhost,1433 -U sa -P "$MSSQL_SA_PASSWORD" -C -I -b \
-Q "SELECT SERVERPROPERTY('EngineEdition'), SERVERPROPERTY('Edition'), SERVERPROPERTY('ProductMajorVersion')"
Measured: EngineEdition 3, Enterprise Developer Edition (64-bit), ProductMajorVersion 17, on
Ubuntu 24.04. The patch level moves under the 2025-latest tag without the template changing:
17.0.4085.5, RTM-CU8-GDR, on 2026-09-17, and 17.0.5005.3, RTM-CU9, after a fresh pull on
2026-09-18. Read it rather than quote it. The sample database comes up at compatibility level 170. The Azure SQL Database engine answers 5 and
SQL Azure, so if this query ever returns 5 the templates have changed engine and this section
needs rewriting rather than reinterpreting.
Where that difference shows, measured in the same session:
| Statement | Answer here | Why it matters |
|---|---|---|
SELECT TOP 1 * FROM sys.dm_db_resource_stats |
Msg 208, Invalid object name |
a cloud-only view. Resource and throttling questions cannot be rehearsed here |
ALTER DATABASE Library MODIFY (SERVICE_OBJECTIVE = 'S0') |
Msg 102, Incorrect syntax near '(' |
there is no service objective on this engine |
CREATE USER [probe@example.com] FROM EXTERNAL PROVIDER |
Msg 33134, Unable to query Azure AD certificate from local cert store |
Microsoft Entra principals stay a cloud concern here |
BACKUP DATABASE Library TO DISK = '/var/opt/mssql/probe.bak' |
accepted, 554 pages | Azure SQL Database refuses it outright. Do not learn a backup habit here |
USE Library |
accepted | Azure SQL Database refuses a database switch on a connection |
CREATE TABLE #v (id int, e vector(3)) with VECTOR_DISTANCE('cosine', ...) |
accepted, returned 0.285714328289032 |
the vector type and the distance function are present |
CREATE VECTOR INDEX |
Msg 343, Unknown object type 'VECTOR' used in a CREATE, DROP, or ALTER statement |
until ALTER DATABASE SCOPED CONFIGURATION SET PREVIEW_FEATURES = ON has run in that database in an earlier batch, after which the same statement is accepted. The templates ship it off |
None of those seven answers tells you what Azure SQL Database will do. Six of them tell you what it will not do, which is useful in the other direction, and the seventh, the vector index, is a local switch rather than a cloud one.
Up is now ready, and the mechanism is worth knowing
Compose waits until a container is running, not until it is usable, and the application service
carries network_mode: service:db, which on its own synthesises depends_on: db: condition: service_started. That combination was the gap in 1.2.2. Version 2.0.0 closes it in the files:
docker compose -f .devcontainer/docker-compose.yml config | grep -A3 'depends_on:'
The database service declares a health check that logs in, and the application service declares
condition: service_healthy, which survives alongside network_mode. Measured: the check runs
every 10 seconds with a 2 second start interval inside a 120 second start period, and the
application container started 10.1 seconds after the database container, once the login succeeded.
Nothing has to be added to the template.
Two things this does not do. It does not make docker ps meaningful: give the engine a password
it refuses and the database container still reports Up while the engine never finishes starting, so a
login is still the only proof. And it does not hold the editor, because the dev container
specification defaults waitFor to updateContentCommand, which leaves postCreateCommand
running in the background while the editor attaches. A terminal opened early can find the schema
still deploying. Wait for it the same way the create-time script does:
until sqlcmd -S localhost,1433 -U sa -P "$MSSQL_SA_PASSWORD" -C -I -b -l 5 -Q "SELECT 1" >/dev/null 2>&1; do sleep 2; done
-l 5 bounds the login wait, without which a poll against a half-started engine can hang instead
of being refused. -C trusts the self-signed certificate. -I sets QUOTED_IDENTIFIER ON.
The four templates
All four are version 2.0.0. They share one database service, byte for byte, one sample database and one create-time script, and they differ in the toolchain image and what is layered on top.
| Template id | Default variant | Ports forwarded | Layered on top |
|---|---|---|---|
dotnet |
10.0-noble (or 8.0-noble) |
5000, 5001, 8000, 1433 | Azure CLI with Bicep, azd, Docker CLI, SqlPackage 170.5.76 |
dotnet-aspire |
10.0-noble |
5000, 5001, 8000, 1433 | the same, plus the Aspire CLI and project templates, both 13.5.3 |
javascript-node |
24-trixie |
3000, 1433 | the .NET 10 feature for the schema project, plus the same tools |
python |
3.14-trixie |
5000, 1433 | the same, plus mssql-python 1.14.0 in place of pyodbc |
The variant is the imageVariant option and becomes the base image tag, so 24-trixie builds
javascript-node:5-24-trixie and 3.14-trixie builds python:3-3.14-trixie. The schema project is
the SDK-style Microsoft.Build.Sql/2.2.0 in every template. Those pins were read from the files at
tag v2.0.0 on 2026-09-18, which is also the head of main that day.
Check the toolchain arrived rather than assume the layer ran:
dotnet --list-sdks # every template needs one for the schema project
sqlpackage /version # a .NET tool installed at create time, not part of the image
azd version
Measured in the dotnet container: .NET SDK 10.0.401, SqlPackage 170.5.76.0, azd 1.34.0, Azure CLI
2.90.0, and sqlcmd 1.10.0, which is the Go build rather than the ODBC one. The .NET SDK in the
Node.js and Python templates is the price of the schema project; removing it removes the schema
deployment.
Two containers, one network namespace
The application service carries network_mode: service:db, so it joins the database container's
network stack rather than getting its own. Each consequence was checked:
-
localhost,1433reaches the database from inside the application container, and so dodband the hostnameSQL-Library. All three logged in. -
The application container's hostname is
SQL-Library, so anything deriving identity from the hostname sees the database container's name, and so does every message the engine prints. -
The database service declares no volume, so removing that container discards the database, and the schema returns only because the create step reruns.
-
A
ports:block on the application service is refused, and validating the file will not tell you.docker compose configaccepts a service carrying bothnetwork_mode: service:dbandports:, exit 0. The daemon refuses it at create time:Error response from daemon: conflicting options: port publishing and the container type network modeforwardPortsindevcontainer.jsonis the mechanism instead, and unlike a published port it works in a cloud workspace. -
Two stacks run at once. 1.2.2 hardcoded
container_name: SQL-Libraryand the second stack died on a name conflict. No template carriescontainer_namenow. A second copy of the same template started while the first was up, with the samehostname:value, because a hostname is scoped to its own network and a container name is not.
The create-time contract
One line in devcontainer.json does the schema deployment, and in 2.0.0 it takes no argument:
"postCreateCommand": "bash .devcontainer/sql/postCreateCommand.sh"
The script waits for a login, builds database/Library, then publishes
database/Library/bin/Debug/Library.dacpac with SqlPackage. Five things follow:
- The build is no longer inside the deployment gate. 1.2.2 looked for a dacpac first and
built only if it found one, so a clean checkout deployed nothing and said nothing. 2.0.0 builds
every time, which is why no dacpac and no
binorobjdirectory is committed any more. - A failed build never publishes. Measured: with one line of invalid T-SQL added to
database/Library/Tables/books.sql, the script printedBuild error SQL46010andpostCreateCommand.sh failed during: build database/Library (exit 1), exited 1, and theLibrarydatabase was not created. An empty database is now an error you can see. - The build is the compatibility check, so read a build failure as a finding. An error
such as
SQL70015is the target platform refusing a construct Azure SQL Database does not have, not a broken toolchain. - The database name comes from the dacpac file name.
Library.dacpacgives a database namedLibrary, so renaming the.sqlprojrenames the database anddevcontainer.jsonnever says so. - It runs at create, not at every start.
postStartCommandis the every-start hook. After a schema change, republish rather than restarting and expecting a redeploy:
bash .devcontainer/sql/postCreateCommand.sh
Where the credential lives, and how it reaches the saved profile
MSSQL_SA_PASSWORD is set in .devcontainer/.env, and in 2.0.0 env_file is on both
services, so the variable exists in the application container you develop in as well as in the database. The
create-time script reads it from the environment rather than parsing the file, so a password
containing a space or a quote no longer loses everything after the first space.
The saved LocalDev connection profile is contributed by devcontainer.json, and the placeholder
it uses is the whole story. Measured with the dev container CLI 0.89.0 against a running container,
with a different value exported on the host to tell the two sources apart:
| In the profile | Resolves to |
|---|---|
${containerEnv:MSSQL_SA_PASSWORD} |
the value inside the container, which is what .env set |
${env:MSSQL_SA_PASSWORD} |
the host's environment, empty in the ordinary case |
${localEnv:MSSQL_SA_PASSWORD} |
the host's environment, the same empty value |
2.0.0 uses ${containerEnv:MSSQL_SA_PASSWORD}. Versions from 1.2.2 used ${env:...}, which left
the saved profile with an empty password, and an empty password is not an error: the editor simply
prompts, so it reads as a forgotten credential rather than a broken template. The extension itself
expands nothing at all, so the substitution has to happen in the dev container tooling, which is
why only the containerEnv form works. If you edit that profile, keep the form.
Keep the sample password free of whitespace and quoting, and never reuse it outside the workspace; it is committed in plaintext in a public repository.
On Apple silicon, one container is emulated
The database image is x64 only, and the compose file says so with platform: linux/amd64.
Measured on an Apple silicon host: the application container reports aarch64 and runs natively,
the database container reports x86_64 and runs under emulation. Microsoft does not test or
support the engine under emulation, and the project's own release notes record it aborting during
startup roughly 1 start in 37 under emulation, and once on a native x64 runner. Three starts in
this session were clean. The symptom is a container that exits or a stack that never reaches
healthy; recreating it clears it, and nothing is preserved that a second attempt would inherit.
The database service is also capped at 2 GB, the documented minimum. Raise it in
docker-compose.yml before blaming a query for being slow.
On a restricted network, creation fails while installing tools
Not measured here, and stated as a pointer rather than as a result: behind a proxy that blocks
api.nuget.org, the npm registry or PyPI, the image builds and creation then fails inside
onCreateCommand or postCreateCommand, with a bare TLS or service-index error that names the
registry and nothing else. Each template's NOTES.md carries a "Troubleshooting: restricted
networks" section listing the hosts each toolchain needs.
Check it worked
Four checks, each a command rather than an impression:
# 1. the engine is the one you think, and is accepting logins at all
sqlcmd -S localhost,1433 -U sa -P "$MSSQL_SA_PASSWORD" -C -I -b \
-Q "SELECT SERVERPROPERTY('EngineEdition') AS engine_edition"
# 2. the build produced exactly one dacpac for the publish step to use
ls -1 database/Library/bin/Debug/*.dacpac | wc -l
# 3. a database named after that dacpac exists
sqlcmd -S localhost,1433 -U sa -P "$MSSQL_SA_PASSWORD" -C -I -b \
-Q "SELECT name FROM sys.databases WHERE database_id > 4"
# 4. the schema landed, not just the database
sqlcmd -S localhost,1433 -U sa -P "$MSSQL_SA_PASSWORD" -C -I -b -d Library \
-Q "SELECT COUNT(*) AS user_tables FROM sys.tables"
Expect 3 from the first, 1 from the second, Library from the third and 3 from the fourth.
A database present with zero user tables means the publish half ran and the build half did not,
which in 2.0.0 also means the create step exited non-zero and said so.
One caveat on reading those answers. The sqlcmd on the application container's PATH is the Go
build, measured at 1.10.0, and it prints a severity 10 message with no Msg number whatever -m
is set to. The ODBC build inside the database image, measured at 18.6.0002.1 and reachable as
/opt/mssql-tools18/bin/sqlcmd there, prints Msg 50000, Level 0 for the same message. When a
warning matters, run it through the database container.
References
.devcontainer/docker-compose.yml: read it first, every time; the image, the shared namespace, the health check and the memory cap are decided there..devcontainer/sql/postCreateCommand.sh: open it when the create step reported success and the database is empty, or when you want the exact publish command to rerun by hand.src/<template>/NOTES.mdin the template repository: open it for the restricted-network host list and the per-template tasks, because the applied workspace does not contain this file.- SERVERPROPERTY when the EngineEdition numbers are disputed.
- The dev container specification for
waitForand for the variable forms a profile may use, and Compose startup order fordepends_onconditions, before changing when the schema deployment runs.