Azure  

Azure SQL Developer Container: Building Reproducible Local SQL Server Environments for .NET

Local database setup is one of the most common sources of inconsistency in .NET development. One developer may use SQL Server installed directly on Windows, another may use a different SQL Server version, and a third may connect to a shared development database.

These differences can create problems that are difficult to reproduce.

Containers provide a practical alternative. By running SQL Server in a container, development teams can define a consistent database environment that can be started, stopped, recreated, and integrated into local development workflows.

This article demonstrates how to build a reproducible SQL Server development environment for an ASP.NET Core application using containers and Docker Compose.

Why Use a SQL Server Developer Container?

A traditional local setup may look like this:

Developer Machine
    |
    +---- SQL Server Installation
    |
    +---- Database
    |
    +---- ASP.NET Core Application

The database configuration is tied directly to the developer's machine.

A containerized approach changes this:

Developer Machine
    |
    +---- ASP.NET Core Application
    |
    +---- SQL Server Container
             |
             +---- Database

The SQL Server environment becomes part of the project's development configuration.

This makes it easier for developers to work with the same database engine version, environment variables, initialization process, and connection configuration.

Prerequisites

Before creating the environment, install:

  • Docker Desktop or an equivalent Docker runtime

  • .NET SDK

  • An ASP.NET Core project

  • A SQL Server-compatible client or database management tool

Verify Docker:

docker --version

Verify .NET:

dotnet --version

The exact versions should match the requirements of your application and the SQL Server container image you choose.

Create an ASP.NET Core Project

For a simple demonstration, create an API:

dotnet new webapi -n CustomerApi
cd CustomerApi

The application will eventually connect to SQL Server through a connection string.

A typical project structure can look like:

CustomerApi/
├── Controllers/
├── Data/
├── Models/
├── Services/
├── Program.cs
├── appsettings.json
└── CustomerApi.csproj

Keeping database-related code under a dedicated area makes the project easier to maintain as it grows.

Create the SQL Server Container

A Docker Compose configuration is convenient when the application and database are part of the same local development environment.

Create a docker-compose.yml file:

services:
  sqlserver:
    image: mcr.microsoft.com/mssql/server:2022-latest
    container_name: customer-sqlserver
    environment:
      ACCEPT_EULA: "Y"
      MSSQL_SA_PASSWORD: "${SQL_SA_PASSWORD}"
    ports:
      - "1433:1433"
    volumes:
      - sqlserver-data:/var/opt/mssql

volumes:
  sqlserver-data:

The important settings are:

  • ACCEPT_EULA accepts the image's license requirement.

  • MSSQL_SA_PASSWORD supplies the SQL Server administrator password.

  • 1433:1433 exposes SQL Server to the host.

  • The named volume preserves database files when the container is recreated.

The password should not be committed to source control.

Store Local Secrets Separately

Create a local .env file:

SQL_SA_PASSWORD=Use-A-Strong-Local-Password

Then make sure it is ignored by Git:

.env

Do not place production credentials in this file.

A local container password is only for the development environment.

Start the Database

Start the SQL Server container:

docker compose up -d

Check the running container:

docker compose ps

You can inspect the logs with:

docker compose logs sqlserver

SQL Server may require some time to become ready after the container starts.

This distinction matters because:

Container Started
        ≠
SQL Server Ready

An application that starts immediately may receive a connection failure if SQL Server has not finished initializing.

Check SQL Server From the Container

You can inspect the container:

docker exec -it customer-sqlserver bash

This is useful for troubleshooting container-level problems.

For routine database administration, however, a database client or migration process is usually more convenient than manually changing the container.

Configure the ASP.NET Core Connection String

A development connection string might look like:

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost,1433;Database=CustomerDb;User Id=sa;Password=YOUR_PASSWORD;TrustServerCertificate=True;"
  }
}

Do not commit a real password to appsettings.json.

For local development, ASP.NET Core configuration can be supplied through environment variables or user secrets.

For example:

dotnet user-secrets init

Then:

dotnet user-secrets set \
  "ConnectionStrings:DefaultConnection" \
  "Server=localhost,1433;Database=CustomerDb;User Id=sa;Password=YOUR_PASSWORD;TrustServerCertificate=True;"

The connection string format should be appropriate for the SQL Server version and driver used by the application.

Register Entity Framework Core

If the application uses Entity Framework Core, configure the SQL Server provider.

For example:

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

var connectionString =
    builder.Configuration.GetConnectionString(
        "DefaultConnection");

builder.Services.AddDbContext<CustomerDbContext>(
    options =>
        options.UseSqlServer(connectionString));

builder.Services.AddControllers();

var app = builder.Build();

app.MapControllers();

app.Run();

This keeps the connection string in configuration rather than hard-coding database connection details into the application.

Create the DbContext

A simple context can look like:

using Microsoft.EntityFrameworkCore;

public sealed class CustomerDbContext
    : DbContext
{
    public CustomerDbContext(
        DbContextOptions<CustomerDbContext> options)
        : base(options)
    {
    }

    public DbSet<Customer> Customers =>
        Set<Customer>();
}

The model can be defined as:

public sealed class Customer
{
    public int Id { get; set; }

    public string Name { get; set; } = string.Empty;

    public string Email { get; set; } = string.Empty;
}

This provides a minimal database layer for the example.

Create the Initial Migration

Install or use the Entity Framework Core CLI tooling required by your project, then create a migration:

dotnet ef migrations add InitialCreate

Apply it:

dotnet ef database update

The workflow becomes:

Code Model
    ↓
EF Migration
    ↓
SQL Server Container
    ↓
CustomerDb

This makes database schema changes reproducible.

Why Migrations Matter

Without migrations, developers may manually create tables differently.

For example:

Developer A
    ↓
Customers.Name = nvarchar(100)

Developer B
    ↓
Customers.Name = nvarchar(255)

Both developers believe they have the same database, but the schemas differ.

Migrations provide a versioned representation of database changes.

They should still be reviewed like application code.

Adding a Health Check

A local environment becomes easier to troubleshoot when the application can report database connectivity.

Register a SQL Server health check:

builder.Services
    .AddHealthChecks()
    .AddSqlServer(connectionString);

Then map the endpoint:

app.MapHealthChecks("/health");

Now:

GET /health

can provide a quick indication that the application's database dependency is reachable.

For more sophisticated environments, health checks should distinguish between application startup, dependency availability, and readiness.

Add Database Initialization Carefully

Developers sometimes initialize databases automatically when the application starts:

using var scope = app.Services.CreateScope();

var db =
    scope.ServiceProvider
        .GetRequiredService<CustomerDbContext>();

db.Database.Migrate();

This can be convenient for a local development environment.

However, automatically applying migrations during application startup should be considered carefully for shared or production environments.

A safer separation is:

Development
    ↓
Automatic / Developer-Controlled Migration

Production
    ↓
Explicit Deployment Migration

Database schema changes should be part of the deployment process rather than an unexpected side effect of application startup.

Add a Health-Aware Startup Strategy

The application should not assume SQL Server is immediately ready.

A Compose environment can use a health check:

services:
  sqlserver:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      ACCEPT_EULA: "Y"
      MSSQL_SA_PASSWORD: "${SQL_SA_PASSWORD}"
    ports:
      - "1433:1433"
    volumes:
      - sqlserver-data:/var/opt/mssql
    healthcheck:
      test:
        [
          "CMD-SHELL",
          "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$${MSSQL_SA_PASSWORD}\" -C -Q \"SELECT 1\""
        ]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 20s

The exact SQL command path can vary between SQL Server container image versions, so verify it against the image being used.

The purpose of the health check is to distinguish:

Container Running

from:

SQL Server Ready

Persisting Database Data

The named volume:

volumes:
  sqlserver-data:

allows SQL Server files to persist independently of the container lifecycle.

This means:

docker compose down

does not necessarily mean that the database files disappear.

If you intentionally want to remove the database volume:

docker compose down -v

This distinction is important.

Developers sometimes run down -v while troubleshooting and accidentally remove the local database state.

Recreating a Clean Database

One of the biggest advantages of containers is the ability to reproduce a clean environment.

For example:

docker compose down -v
docker compose up -d
dotnet ef database update

The workflow becomes:

Delete Local Database
        ↓
Create Fresh SQL Container
        ↓
Apply Migrations
        ↓
Ready Development Environment

This is extremely useful when testing database migrations or reproducing schema-related bugs.

Containerized SQL Server vs Local Installation

AreaLocal SQL ServerSQL Server Container
SetupMachine-specificProject-defined
ReproducibilityLowerHigher
IsolationLimitedStronger
Reset environmentMore involvedSimple
Version consistencyRequires manual managementImage-based
CI integrationMore setupEasier to automate
Resource usageDepends on installationContainer-dependent
Production similarityDepends on setupCan be standardized

Containers do not automatically make a database environment identical to production.

They simply provide a more controlled and repeatable development environment.

Common Mistakes

Committing Database Passwords

Never commit:

MSSQL_SA_PASSWORD: "RealProductionPassword"

Use environment variables or appropriate secret-management mechanisms.

Using localhost Inside Containers

If the ASP.NET Core application itself runs inside Docker Compose, this is incorrect:

Server=localhost

Inside the application container, localhost refers to the application container itself.

The SQL Server service should instead be addressed using its Compose service name:

Server=sqlserver,1433

This is one of the most common container networking mistakes.

Assuming Startup Means Readiness

A running SQL Server container may still be initializing.

Using sa Everywhere

The sa account is convenient for local development, but applications should use an appropriately scoped database identity where practical.

Deleting Volumes Accidentally

Be careful with:

docker compose down -v

because it removes named volumes associated with the Compose project.

Treating Local Configuration as Production Configuration

A local SQL Server container should not become a template for production credential and networking practices without proper security review.

Best Practices

  1. Pin or deliberately manage the SQL Server image version.

  2. Keep passwords outside source control.

  3. Use Docker Compose for repeatable local setup.

  4. Use EF Core migrations for schema versioning.

  5. Separate development and production configuration.

  6. Add database health checks.

  7. Distinguish container startup from database readiness.

  8. Use named volumes when persistent local data is required.

  9. Use sqlserver rather than localhost when both services run inside Compose.

  10. Use dedicated application credentials instead of relying on sa where practical.

  11. Document database initialization steps.

  12. Test migrations against a clean database regularly.

  13. Keep database initialization separate from production startup where appropriate.

Advantages and Disadvantages

Advantages

  • Reproducible local SQL Server environment

  • Easier onboarding for developers

  • Consistent database engine configuration

  • Simple environment reset

  • Works well with automated development workflows

  • Reduces machine-specific database configuration

  • Makes migration testing easier

Disadvantages

  • Requires Docker knowledge

  • Containers consume system resources

  • Database startup can be slower than an already-running local service

  • Persistent volumes can consume significant disk space

  • Container networking introduces additional configuration

  • Local container behavior may still differ from production infrastructure

Troubleshooting

SQL Server Container Keeps Restarting

Check:

docker compose logs sqlserver

Common causes include invalid environment configuration, password requirements, insufficient resources, or image-specific startup problems.

Application Cannot Connect

Check whether the application runs:

On the host

or:

Inside Docker

From the host:

Server=localhost,1433

From another Compose service:

Server=sqlserver,1433

Login Fails

Verify the configured username and password and make sure the application is using the same configuration source you expect.

Database Does Not Exist

Apply the EF Core migration:

dotnet ef database update

Database Disappeared

Check whether the container was started with a persistent volume and whether the volume was removed with:

docker compose down -v

Port 1433 Is Already in Use

Check which local process or container is using the port.

You can change the host-side port:

ports:
  - "11433:1433"

The container still listens on 1433, while the host connects through 11433.

A Practical Development Workflow

A simple team workflow can be:

Clone Repository
      ↓
Create Local Environment File
      ↓
docker compose up -d
      ↓
Check SQL Server Health
      ↓
Run EF Migrations
      ↓
Start ASP.NET Core Application
      ↓
Run Tests
      ↓
Develop

A new developer can therefore reproduce the database environment without manually installing and configuring a specific SQL Server instance.

Conclusion

A containerized SQL Server environment provides a practical way to make local .NET development more consistent and reproducible. Instead of depending on individual developer machines, the database engine, environment configuration, persistence model, and initialization process can be represented as part of the development workflow.

The most important considerations are reproducibility, secure configuration, database readiness, schema versioning, container networking, and data persistence.

For local development, combining SQL Server containers with Docker Compose and EF Core migrations creates a clean workflow:

Docker Compose
      +
SQL Server
      +
EF Core Migrations
      +
ASP.NET Core
      ↓
Reproducible Development Environment

The result is not merely a convenient way to run SQL Server. It provides developers with a repeatable environment that can be recreated when investigating database issues, testing migrations, onboarding new team members, or preparing automated development workflows.