Nitin Kumar SinghSolutions Architect

Type to search. to move, Enter to open.

    move open esc close
    Deep Dive.NETPart 11 of 12

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

    How to containerize .NET Core and SQL Server with Docker — optimized Dockerfiles, multi-container Docker Compose setups, and networking for dev and prod.

    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.


    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.


    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.


    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.

    Comments

    Comments are GitHub discussions. Sign in with GitHub to post; reactions need no account.