Multi-container .NET and SQL Server setups with Docker Compose

A typical .NET solution rarely lives on its own anymore. Even a modest web API quickly picks up dependencies: a database, a cache, a message broker, maybe a reverse proxy sitting in front of everything during local testing. Running all of that on a developer laptop tends to create friction, especially when teammates have slightly different Windows builds, SQL Server versions, or .NET SDK installations.

Docker Compose offers a way out. By declaring each piece of the stack in a single YAML file, you can spin up the whole environment with one command, share it with the rest of the team, and tear it down when the work is done. For .NET developers working with SQL Server, this is often the simplest path to reproducible local environments that behave the same on every machine.

Why multi-container setups matter

A single Dockerfile is fine for an isolated microservice, but most real applications have at least two moving parts. Even a small API project usually pairs the application container with a database container. Add a Redis instance for caching, or a RabbitMQ broker for asynchronous work, and you quickly have a small fleet of services that need to start, stop, and restart together.

Splitting those services into separate containers keeps responsibilities clean. The .NET image only needs the SDK or runtime, the SQL Server image only needs the database engine, and nothing tries to be everything at once. From a debugging perspective, this also makes it easy to recreate a broken state without touching your main machine: a corrupted volume can be wiped, a misbehaving container can be replaced, and the developer is back to a known-good starting point in seconds.

For teams spread across cities like Sydney, Melbourne, and Brisbane, this matters even more. Everyone is working off the same compose file rather than a wiki page full of "works on my machine" instructions. New starters can clone the repo, run docker compose up, and have a working stack before lunch.

The docker-compose.yml file essentials

A Compose file is declarative. Instead of writing a long script that starts things in a specific order, you describe what you want and Docker figures out the execution. For a .NET API plus SQL Server, the file typically has two services: one for the application and one for the database.

The services section is where each container is defined. Each service gets its own image or build context, its own ports, and its own environment configuration. A minimal docker-compose.yml for a .NET 8 Web API connecting to SQL Server 2022 ends up being just a few dozen lines, covering an api service that references a Dockerfile in the project root and a db service backed by the official mcr.microsoft.com/mssql/server:2022-latest image.

Volumes sit at the same indentation level as services and are how you keep data alive between restarts. The networks section is optional in simple cases because Compose creates a default network, but it becomes important once you start building more complex topologies or want to isolate parts of the stack from each other.

Networking between containers

Compose places every service in a default network, which means the API can reach the database using the service name as the hostname. Writing Server=db; in your connection string works because Docker resolves db to the container's IP on the internal network. This is one of those small details that catches people out the first time: there is no localhost between two containers, even when they live on the same developer laptop.

When you want to expose the database to host tools like Azure Data Studio or sqlcmd, you publish a port on the host, for example 1433:1433. The internal port stays reachable from other containers, while the host port is what you connect to from your local tooling. This separation is useful when running multiple stacks on the same machine, since you can remap ports without touching the internal networking at all.

For production-leaning setups, defining an explicit network lets you control which services can talk to each other. You might want the API on one network and the database on another, joined only by a small set of intermediate services. Compose supports this through the networks key on each service, with multiple network entries supplied as a list.

Persisting data with volumes

Containers are meant to be disposable, but the data inside them usually is not. Without a volume, removing the database container wipes the schema, the seed data, and any test rows that were added during the morning's debugging session. Named volumes fix this by mapping a directory on the host, managed by Docker, to a path inside the container.

A typical pattern uses a named volume for SQL Server's data and log directories. The compose entry looks something like db-data:/var/opt/mssql/data, paired with a top-level volumes: block that gives the volume a name. From that point on, the data survives restarts, rebuilds, and even the removal of the container itself, which is exactly what you want for a long-lived developer environment.

If your tests rely on a clean database, you can pair the named volume with a docker compose down -v command that strips the volume entirely. This is sometimes added to the project's Makefile or PowerShell scripts, giving every team member a reliable factory reset without anyone needing to remember the exact flag.

For Australian developers on variable NBN connections or shared corporate VPNs, named volumes also reduce re-pull pressure. Rebuilding a container is fast because the image cache stays warm, and the database volume itself is just a directory on disk rather than something that needs to be downloaded each time the stack comes up.

Environment variables and configuration

Secrets and connection strings rarely belong inside a compose file that ends up in source control. The standard pattern is to keep non-sensitive defaults in docker-compose.yml, then layer overrides from a .env file or an environment block pulled from the host. Tools like User Secrets in .NET or environment variables set in the shell can feed straight into the compose file through variable substitution.

The appsettings.Development.json for the API can reference the same ConnectionStrings__Default variable that the compose file passes in. Docker Compose respects __ as a hierarchy separator for ASP.NET configuration providers, so a value like ConnectionStrings__Default=Server=db;Database=app;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True becomes the right shape without any extra plumbing on the application side.

Time zone handling is another area worth setting explicitly. Australian developers often pass TZ=Australia/Sydney so log timestamps match local AEDT or AEST, depending on the time of year. This avoids the classic confusion where a container running on UTC logs an event at 03:00 while the on-call developer is reading it at midday.

Common pitfalls and how to avoid them

A few mistakes show up repeatedly when teams adopt Compose for the first time. The first is forgetting that SQL Server needs a strong SA password and a licence-acceptance flag. The official image refuses to start without ACCEPT_EULA=Y in the environment, and a password below the complexity threshold gets logged as a startup error that looks like a connection failure rather than a configuration issue.

Another is publishing port 1433 on the host without thinking. In a shared office in Cremorne or Surry Hills, you may be on a network where another developer's SQL Server is already bound to that port. Mapping to 1433:1433 only works on your machine; on someone else's laptop it collides. Using a less common host port, such as 14330:1433, avoids the conflict without changing anything inside the container.

Volume permissions are a third source of pain. SQL Server's Linux image runs as a non-root user, and if the named volume is mounted with the wrong ownership on a Linux host, the container fails to write data. Letting Docker create the volume itself, rather than bind-mounting a host directory, sidesteps this entirely.

Finally, image cleanup tends to be ignored. A development workflow can leave behind a dozen stopped containers and half a dozen dangling images. A periodic docker system prune keeps things tidy and prevents the dreaded "no space left on device" error mid-build, which is a particular nuisance for developers working on smaller SSDs.

Integrating with your development workflow

Once the compose file works, the next step is making it invisible. Adding docker compose up -d to the project's pre-build step, or wiring it into a Visual Studio launch profile, means the stack starts automatically when the developer hits run. The .NET project itself can execute either inside a container or directly on the host while still talking to the containerised database, which is often the most practical setup during early development.

CI pipelines benefit just as much. The same compose file that runs locally can be invoked in a GitHub Actions or Azure DevOps pipeline with a slightly different override file. Tests run against a real SQL Server rather than an in-memory fake, catching schema mismatches, collation quirks, and T-SQL syntax issues that would otherwise slip through.

For teams operating under stricter compliance, such as healthcare or financial services regulated in line with the Privacy Act and the Notifiable Data Breaches scheme, running SQL Server inside a controlled container also makes it easier to apply the ACSC Essential Eight mitigations. Image scanning, least-privilege service accounts, and reproducible builds all line up well with a Compose-based workflow.

If you have been wrestling with mismatched local SQL Server versions or "it works on my machine" Slack threads, give Docker Compose a serious try on your next .NET project. Start small with just the API and database, get comfortable with the workflow, and expand outward as the rest of your stack grows. The first time a new teammate runs docker compose up and has the whole environment running in under five minutes, the value becomes obvious.