Remigiusz ZalewskiRemigiusz Zalewski

Docker Compose with .NET, React, and MSSQL

docker-compose-dotnet-react-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

  • localhost in the connection string. Use the service name (database.server).
  • depends_on without a health check. The API races SQL Server's startup and loses.
  • Committing SA_PASSWORD and cert passwords. Fine for a local demo; use an .env file 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 + restore is 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.yml defines the API, the React client, and SQL Server, and docker compose up starts the whole stack.
  • Services reach each other by service name over Compose's shared network - never localhost.
  • depends_on with condition: service_healthy plus a SELECT 1 health 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.

Resources