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:
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
| Area | Local SQL Server | SQL Server Container |
|---|
| Setup | Machine-specific | Project-defined |
| Reproducibility | Lower | Higher |
| Isolation | Limited | Stronger |
| Reset environment | More involved | Simple |
| Version consistency | Requires manual management | Image-based |
| CI integration | More setup | Easier to automate |
| Resource usage | Depends on installation | Container-dependent |
| Production similarity | Depends on setup | Can 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
Pin or deliberately manage the SQL Server image version.
Keep passwords outside source control.
Use Docker Compose for repeatable local setup.
Use EF Core migrations for schema versioning.
Separate development and production configuration.
Add database health checks.
Distinguish container startup from database readiness.
Use named volumes when persistent local data is required.
Use sqlserver rather than localhost when both services run inside Compose.
Use dedicated application credentials instead of relying on sa where practical.
Document database initialization steps.
Test migrations against a clean database regularly.
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.