Docker Compose with .NET, React, and MSSQL

Introduction
"Clone the repo, install the .NET SDK, install Node, install SQL Server, run these three migrations, start these two projects in this order" is the onboarding doc nobody enjoys. Docker Compose replaces it with docker compose up. One file describes every service, how it is built, how they connect, and what order they start in.
This video containerizes a full stack on .NET 8: an ASP.NET Core API, a React client, and SQL Server, all defined in one docker-compose.yml. The interesting parts are how the containers find each other, how the API waits for the database to actually be ready, and how HTTPS works inside a container.
🎬 Watch the full video here:
Three services in one file
services:
movies.api:
build:
context: .
dockerfile: Movies.Presentation/Dockerfile
ports: ['3000:3000', '3001:3001']
depends_on:
database.server:
condition: service_healthy
database.server:
image: 'mcr.microsoft.com/mssql/server'
ports: ['1433:1433']
environment:
- ACCEPT_EULA=y
- SA_PASSWORD=SuperPassword123
movies.client:
build:
context: Movies.Presentation/Client/movies
dockerfile: Dockerfile
ports: ['5000:5000']
depends_on:
- movies.api
The API and client are built from local Dockerfiles; SQL Server is pulled as a ready-made Microsoft image and configured entirely through environment variables.
Containers find each other by service name
This is the key networking idea. Compose puts every service on a shared network, and the service name is a DNS hostname. So the API's connection string does not say localhost - it says database.server:
Server=database.server,1433;Database=MoviesDb;User Id=SA;Password=SuperPassword123;TrustServerCertificate=True;
localhost inside the API container means "the API container itself", not the host machine and not the database container. Use the service name and Compose resolves it to the right container IP.
Waiting for the database to be ready
depends_on alone only waits for the database container to start, not for SQL Server inside it to accept connections - and SQL Server takes a good few seconds to initialize. Without handling that, the API starts, tries to connect or migrate, and crashes.
The fix is a health check on the database service plus condition: service_healthy on the dependent:
database.server:
healthcheck:
test: /opt/mssql-tools/bin/sqlcmd -S localhost -U SA -P "SuperPassword123" -Q "SELECT 1" -b
interval: 10s
retries: 10
start_period: 10s
Now Compose runs SELECT 1 against SQL Server on a loop and does not start movies.api until that succeeds. The API code also calls dbContext.Database.Migrate() on startup so the schema is created on first run.
The multi-stage Dockerfile
The API image uses the standard pattern - sdk image to restore, build, and publish; aspnet runtime image for the final image; copy only the publish output across. The .csproj files are copied and restored before the rest of the source so Docker can cache the restore layer and skip it when only code changes.
The React Dockerfile is simpler here: a Node image, npm install, and npm run dev for the Vite dev server on port 5000.
HTTPS inside a container
The API listens on both 3000 (HTTP) and 3001 (HTTPS). For HTTPS to work in the container, it needs a dev certificate, which is mounted from the host rather than baked into the image:
movies.api:
environment:
- ASPNETCORE_Kestrel__Certificates__Default__Path=/https/movies.pfx
- ASPNETCORE_Kestrel__Certificates__Default__Password=123
volumes:
- ~/.aspnet/https:/https:ro
You export the ASP.NET Core dev cert to ~/.aspnet/https once with dotnet dev-certs https, and every run mounts it read-only. Keeping the cert and its password out of the image is the right call.
Data that survives a restart
The database service mounts host folders for its data and log directories:
volumes:
- ./sqlserver/data:/var/opt/mssql/data
- ./sqlserver/log:/var/opt/mssql/log
Without this, docker compose down and your database is gone. With it, the files live on the host and the data persists across container recreation. A named volume is the more portable choice than a bind mount, but both work.
Common pitfalls
localhostin the connection string. Use the service name (database.server).depends_onwithout a health check. The API races SQL Server's startup and loses.- Committing
SA_PASSWORDand cert passwords. Fine for a local demo; use an.envfile or secrets for anything shared, and note SQL Server enforces a password-complexity policy. - No volume on the database. Your data disappears on
down. - Rebuilding on every change. Order the Dockerfile so
COPY *.csproj+restoreis its own cached layer. - Port confusion.
"3000:3000"is host:container - the left side is what you hit from your machine.
Key Takeaways
- One
docker-compose.ymldefines the API, the React client, and SQL Server, anddocker compose upstarts the whole stack. - Services reach each other by service name over Compose's shared network - never
localhost. depends_onwithcondition: service_healthyplus aSELECT 1health check makes the API wait until SQL Server actually accepts connections.- Run EF Core migrations on startup so the schema exists on first run.
- Mount the exported ASP.NET Core dev certificate read-only for in-container HTTPS; keep it out of the image.
- Mount a volume for the database's data directory or you lose everything on
down.
Get the Full Source Code
The complete runnable solution - the compose file, both Dockerfiles, the API with startup migration, and the React client - is available to Patreon supporters. If you want to run docker compose up and get the whole stack instead of rebuilding it from the walkthrough above, you can find it on Patreon.