Skip to main content

Dockerizing the .NET Core API, Angular and MS SQL Server

Dockerizing the .NET Core API, Angular and MS SQL Server
Table of Contents

Getting the Contact Management Application running used to mean installing the .NET SDK, Node, and a local SQL Server, then hoping the versions matched mine. That is three toolchains of setup before anyone sees a login page. Containerizing the stack collapses all of it into docker-compose up --build: the API, the Angular frontend, SQL Server, and an nginx load balancer come up together, wired the same way on every machine. This post walks through the Dockerfiles and compose files that make that work, including the parts I would do differently today.

What you’ll build
#

  • A multi-stage Dockerfile for the .NET Core API that runs as a non-root user
  • A multi-stage Dockerfile for the Angular frontend, served by nginx
  • A docker-compose.yml that orchestrates API, frontend, SQL Server, and an nginx load balancer on one network
  • Database seeding on container startup, plus a debug compose file with hot reloading
This post is part of the Clean Architecture series built around the Contact Management Application. Start with the series introduction for the project structure and the full list of articles.

1. Why Dockerize the Application?
#

Dockerizing the Contact Management Application means it runs the same way on every machine, regardless of the host system. The concrete wins:

  • Consistency: the application and its dependencies are pinned in images, so development, staging, and production run the same bits.

  • Simple onboarding: one compose command replaces three local toolchain installs.

  • Isolation: each container runs on its own, so the app’s SQL Server does not collide with whatever else is on the host.

  • Scalability: containers scale horizontally; adding instances is a compose or orchestrator setting, not a new server build.


2. Setting Up the Dockerfile
#

2.1 .NET Core API
#

To containerize the .NET Core API, create a Dockerfile that describes how to build and run the service. Use a multi-stage build: the SDK image is far bigger than the runtime image, and shipping it to production buys you nothing.

Here is the Dockerfile for the Contact Management Application:

FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 8000
ENV ASPNETCORE_URLS=http://+:8000
RUN groupadd -g 2000 dotnet \
    && useradd -m -u 2000 -g 2000 dotnet
USER dotnet

# This stage is used to build the service project
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
ARG DOTNET_SKIP_POLICY_LOADING=true
WORKDIR /src
COPY ["Contact.Api/Contact.Api.csproj", "Contact.Api/"]
COPY ["Contact.Application/Contact.Application.csproj", "Contact.Application/"]
COPY ["Contact.Domain/Contact.Domain.csproj", "Contact.Domain/"]
COPY ["Contact.Infrastructure/Contact.Infrastructure.csproj", "Contact.Infrastructure/"]
COPY ["Contact.Common/Contact.Common.csproj", "Contact.Common/"]

RUN dotnet restore "./Contact.Api/Contact.Api.csproj"
COPY . .
WORKDIR "/src/Contact.Api"
RUN dotnet build "./Contact.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build

RUN ls /app/build

# This stage is used to publish the service project to be copied to the final stage
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Contact.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false

# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration)
FROM base AS final
WORKDIR /app
# COPY --from=publish /app/publish/Contact.Api.dll .
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Contact.Api.dll"]

Explanation:
#

  1. Base stage: starts from the ASP.NET runtime image, listens on port 8000 via ASPNETCORE_URLS, and creates a non-root dotnet user to run as.

  2. Build stage: uses the .NET SDK image; copying the .csproj files first and restoring before COPY . . keeps the restore layer cached across code changes.

  3. Publish stage: publishes a Release build to a dedicated folder.

  4. Final stage: copies only the published output onto the small runtime image.

2.2 Angular (Frontend)
#

# Create image based off of the official Node image
FROM node:22-alpine as builder

# Copy dependency definitions
COPY package*.json ./

## installing and Storing node modules on a separate layer will prevent unnecessary npm installs at each build
## --legacy-peer-deps as ngx-bootstrap still depends on Angular 14
RUN npm install --legacy-peer-deps && mkdir /app && mv ./node_modules /app/node_modules

# Change directory so that our commands run inside this new directory
WORKDIR /app

# Get all the code needed to run the app
COPY . .

# Build server side bundles
RUN npm run build

# Stage 2: Serve app with nginx server

# Use official nginx image as the base image
FROM nginx:alpine

# Remove default Nginx website
RUN rm -rf /usr/share/nginx/html/*

# Copy the build output to replace the default nginx contents.
COPY --from=builder /app/dist/contacts/browser /usr/share/nginx/html

# Copy the nginx file to fix fallback issue on refreshing.
COPY /nginx.conf /etc/nginx/conf.d/default.conf

# Expose port 80
EXPOSE 8080

# Start Nginx server
CMD ["nginx", "-g", "daemon off;"]

Explanation:
#

  1. Stage 1: build the application. The official Node image restores dependencies (npm install --legacy-peer-deps, because ngx-bootstrap still pins Angular 14 peers) and npm run build compiles the production bundles. Copying package*.json before the source keeps the install layer cached.

  2. Stage 2: serve with nginx. A lightweight nginx:alpine image serves the compiled output; the custom nginx.conf adds the SPA fallback so a refresh on a deep route does not 404.


3. Configuring Docker Compose for the API, Frontend, Loadbalancer (Nginx) and MS SQL Server
#

To orchestrate the .NET Core API, Angular, and MS SQL Server together, we use Docker Compose, which defines and runs all four services as a single unit.

Create a docker-compose.yml file in the root of your project directory:

services:
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    # ports:
    #   - 8080:8080
    depends_on:
      - api
    networks:
      - mssql_network

  api:
    build:
      context: ./backend/src
      dockerfile: Dockerfile
      args:
        - configuration=Release
    # ports:
    #   - 8000:8000
    environment:
      - ASPNETCORE__ENVIRONMENT=${ENVIRONMENT}
      - AppSettings__ConnectionStrings__DefaultConnection=Server=${SQL_SERVER};Database=${SQL_DATABASE};User ID=${SQL_USER};Password=${SQL_PASSWORD};Trusted_Connection=False;Encrypt=False;
      - AppSettings__Secret=${JWT_SECRET}
      - AppSettings__Issuer=${JWT_ISSUER}
      - AppSettings__Audience=${JWT_AUDIENCE}
      - AppSettings__PasswordResetUrl=${PASSWORD_RESET_URL}
      - SmtpSettings__SmtpServer=${SMTP_SERVER}
      - SmtpSettings__Port=${SMTP_PORT}
      - SmtpSettings__Username=${SMTP_USERNAME}
      - SmtpSettings__Password=${SMTP_PASSWORD}
      - SmtpSettings__FromEmail=${SMTP_FROM_EMAIL}
      - SmtpSettings__EnableSsl=${SMTP_ENABLE_SSL}
    depends_on:
      - mssql
    networks:
      - mssql_network
      
  mssql:
    image: mcr.microsoft.com/mssql/server:2022-latest
    container_name: sqlserver_express
    environment:
      - ACCEPT_EULA=Y
      - MSSQL_PID=Express   # Specifies the edition to run as Express
      - MSSQL_SA_PASSWORD=${SQL_PASSWORD}   # Set the SA (System Administrator) password
    ports:
      - "1433:1433"  # Expose SQL Server port 1433
    volumes:
      - mssql_data:/var/opt/mssql  # Persist database data outside of the container
      - ./backend/scripts:/scripts  # Mount for SQL scripts
    entrypoint:
      - /bin/bash
      - -c
      - |
        /opt/mssql/bin/sqlservr & sleep 15s;
        /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P ${SQL_PASSWORD} -d master -i /scripts/seed-data.sql; wait
    networks:
      - mssql_network
  
  nginx: #name of the fourth service
    build: loadbalancer # specify the directory of the Dockerfile
    container_name: nginx
    restart: always
    ports:
      - "80:80" #specify ports forewarding
    depends_on:
      - frontend
      - api
    networks:
      - mssql_network

volumes:
  mssql_data: # Named volume to persist data

networks:
  mssql_network:
    driver: bridge

Explanation:
#

  1. Frontend service: builds the Angular image from its Dockerfile. Note its ports entry is commented out: the frontend is not published to the host, and traffic reaches it only through the nginx load balancer.

  2. API service: builds the .NET Core API image and takes its connection string and secrets from environment variables (read from the .env file at the project root). Like the frontend, it publishes no host port; it listens on 8000 inside the network.

  3. mssql service: pulls the official MS SQL Server 2022 image, sets the SA password via MSSQL_SA_PASSWORD, persists data in the mssql_data volume, and runs a seed script from the custom entrypoint. This is the one backing service published to the host (1433), so you can inspect the database with SSMS.

  4. nginx service: the single public entry point, listening on host port 80 and routing to the frontend and API, so both sit behind one domain, exactly as they would in production.

  5. Network: all four services join the same bridge network, mssql_network, which is what lets the API reach the database as mssql by service name.

Watch out: depends_on only orders container startup; it does not wait for SQL Server to be ready to accept connections. The API can come up before the database is usable. Healthchecks with depends_on: condition: service_healthy are the proper fix.

4. Database Initialization
#

In a real-world application, you often need to initialize the database and apply migrations when starting the application. Here that is done by running a SQL script from the entrypoint when the MS SQL container starts. All env variables are read from the .env file at the root of the project.

mssql:
    image: mcr.microsoft.com/mssql/server:2022-latest
    container_name: sqlserver_express
    environment:
      - ACCEPT_EULA=Y
      - MSSQL_PID=Express   # Specifies the edition to run as Express
      - MSSQL_SA_PASSWORD=${SQL_PASSWORD}   # Set the SA (System Administrator) password
    ports:
      - "1433:1433"  # Expose SQL Server port 1433
    volumes:
      - mssql_data:/var/opt/mssql  # Persist database data outside of the container
      - ./backend/scripts:/scripts  # Mount for SQL scripts
    entrypoint:
      - /bin/bash
      - -c
      - |
        /opt/mssql/bin/sqlservr & sleep 15s;
        /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P ${SQL_PASSWORD} -d master -i /scripts/seed-data.sql; wait
    networks:
      - mssql_network

Now, whenever you build and run the containers, the database comes up seeded with the schema. Honest caveat: that sleep 15s is a guess, not a guarantee. On a slow machine SQL Server may still be starting when sqlcmd fires, and the seed silently fails; the seeding article covers this trade-off in more detail.


5. Running the Application Locally
#

To run the Contact Management Application locally with Docker:

  1. Build and start the stack:
docker-compose up --build

This command builds the .NET Core API and Angular images, pulls the MS SQL Server image, and starts all containers connected via the Docker network.

  1. Access the application: everything is served through the nginx load balancer, so the app is at http://localhost (port 80). Frontend and API share that one domain; no other service publishes an HTTP port.

  2. Verify the database: MS SQL Server is reachable on port 1433. Connect with SQL Server Management Studio (SSMS) or any SQL client using the credentials from your .env file.


6. Docker Compose for Development and Production
#

Docker Compose can be tailored per environment using overrides. The production-style docker-compose.yml above fronts everything with nginx so frontend and API share one domain. For day-to-day development there is a separate docker-compose.debug.yml with hot reloading for both the API and the frontend.

Example docker-compose.debug.yml

services:
  frontend:
    build:
      context: ./frontend
      dockerfile: debug.dockerfile
    command: ["npm", "run", "start:debug"]
    ports:
      - 4200:4200
      - 49153:49153
    volumes:
      - ./frontend:/app
      - /app/node_modules
    stdin_open: true
    tty: true
    depends_on:
      - api
    networks:
      - mssql_network
 
  api:
    build:
      context: ./backend/src
      dockerfile: Debug.Dockerfile
    command: ["dotnet", "watch", "--project", "Contact.Api/Contact.Api.csproj", "run", "--urls", "http://0.0.0.0:5000"] 
    ports:
      - 5000:5000

      
    environment:
      - ASPNETCORE__ENVIRONMENT=${ENVIRONMENT}
      - DOTNET_SKIP_POLICY_LOADING=false
      - AppSettings__ConnectionStrings__DefaultConnection=Server=${SQL_SERVER};Database=${SQL_DATABASE};User ID=${SQL_USER};Password=${SQL_PASSWORD};Trusted_Connection=False;Encrypt=False;
      - AppSettings__Secret=${JWT_SECRET}
      - AppSettings__Issuer=${JWT_ISSUER}
      - AppSettings__Audience=${JWT_AUDIENCE}
      - AppSettings__PasswordResetUrl=${PASSWORD_RESET_URL}
      - SmtpSettings__SmtpServer=${SMTP_SERVER}
      - SmtpSettings__Port=${SMTP_PORT}
      - SmtpSettings__Username=${SMTP_USERNAME}
      - SmtpSettings__Password=${SMTP_PASSWORD}
      - SmtpSettings__FromEmail=${SMTP_FROM_EMAIL}
      - SmtpSettings__EnableSsl=${SMTP_ENABLE_SSL}
    volumes:
      - ./backend/src:/app
      - ~/.vsdbg:/remote_debugger:rw
    depends_on:
      - mssql
    networks:
      - mssql_network
      
  mssql:
    image: mcr.microsoft.com/mssql/server:2022-latest
    container_name: sqlserver_express
    environment:
      - ACCEPT_EULA=Y
      - MSSQL_PID=Express   # Specifies the edition to run as Express
      - MSSQL_SA_PASSWORD=${SQL_PASSWORD}   # Set the SA (System Administrator) password
    ports:
      - "1433:1433"  # Expose SQL Server port 1433
    volumes:
      - mssql_data:/var/opt/mssql  # Persist database data outside of the container
      - ./backend/scripts:/scripts  # Mount for SQL scripts
    entrypoint:
      - /bin/bash
      - -c
      - |
        /opt/mssql/bin/sqlservr & sleep 15s;
        /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P ${SQL_PASSWORD} -d master -i /scripts/seed-data.sql; wait
    networks:
      - mssql_network

volumes:
  mssql_data: # Named volume to persist data

networks:
  mssql_network:
    driver: bridge

In debug mode the ports are published directly: the Angular dev server at http://localhost:4200 and the API at http://localhost:5000, with dotnet watch and volume mounts so code changes apply without rebuilding images.


Takeaways
#

  • Multi-stage builds are the baseline, not an optimization: ship the runtime image, keep the SDK and Node toolchains in the build stages.
  • In the production compose, publish only nginx (port 80) and the database; keeping the API and frontend off the host network mirrors how the stack runs behind a real load balancer.
  • Service names on the compose network are your hostnames: the API reaches the database as mssql, not localhost.
  • depends_on and sleep 15s are startup ordering by hope. For anything beyond a demo, use container healthchecks.

What’s next
#

Clone the Contact Management Application, create a .env with the variables the compose file expects, and run docker-compose up --build; the whole stack should be at http://localhost in a few minutes. Then read the follow-up on seeding initial data with Docker Compose and SQL scripts, which digs into the seeding entrypoint used here.

Related

Seeding Initial Data Using Docker Compose and SQL Scripts

··6 mins
Introduction # Nothing kills momentum on a new project like cloning the repo, running docker compose up, and landing on an empty database. No roles, no permissions, no login, so nothing works until someone reads the wiki and hand-runs a SQL script. On the Contact Management Application I wanted docker compose up to be the whole setup: containers come up, the schema seeds itself, and you can log in.

Clean Architecture: Introduction to the Project Structure

··9 mins
Most Clean Architecture articles stop at the circle diagram. The part that actually decides whether a team can live with the pattern is more mundane: which project does this class go in, which direction do the references point, and where do the interfaces live. I built the Contact Management Application as a .NET Core Web API to answer exactly those questions with running code, and this series walks through it layer by layer.

Validating Inputs with FluentValidation

··6 mins
Introduction # Validation is the one part of a request I refuse to trust anywhere but the edge. Domain objects assume they are already valid; rejecting a malformed email or a missing name belongs at the API boundary, before that data reaches a service or a repository.