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 baseWORKDIR /appEXPOSE 8000ENV ASPNETCORE_URLS=http://+:8000RUN groupadd -g 2000 dotnet \ && useradd -m -u 2000 -g 2000 dotnetUSER dotnet
# This stage is used to build the service projectFROM mcr.microsoft.com/dotnet/sdk:9.0 AS buildARG BUILD_CONFIGURATION=ReleaseARG DOTNET_SKIP_POLICY_LOADING=trueWORKDIR /srcCOPY ["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 stageFROM build AS publishARG BUILD_CONFIGURATION=ReleaseRUN 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 finalWORKDIR /app# COPY --from=publish /app/publish/Contact.Api.dll .COPY --from=publish /app/publish .ENTRYPOINT ["dotnet", "Contact.Api.dll"]Explanation:
-
Base stage: starts from the ASP.NET runtime image, listens on port 8000 via
ASPNETCORE_URLS, and creates a non-rootdotnetuser to run as. -
Build stage: uses the .NET SDK image; copying the
.csprojfiles first and restoring beforeCOPY . .keeps the restore layer cached across code changes. -
Publish stage: publishes a Release build to a dedicated folder.
-
Final stage: copies only the published output onto the small runtime image.
2.2 Angular (Frontend)
# Create image based off of the official Node imageFROM node:22-alpine as builder
# Copy dependency definitionsCOPY 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 14RUN npm install --legacy-peer-deps && mkdir /app && mv ./node_modules /app/node_modules
# Change directory so that our commands run inside this new directoryWORKDIR /app
# Get all the code needed to run the appCOPY . .
# Build server side bundlesRUN npm run build
# Stage 2: Serve app with nginx server
# Use official nginx image as the base imageFROM nginx:alpine
# Remove default Nginx websiteRUN 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 80EXPOSE 8080
# Start Nginx serverCMD ["nginx", "-g", "daemon off;"]Explanation:
-
Stage 1: build the application. The official Node image restores dependencies (
npm install --legacy-peer-deps, because ngx-bootstrap still pins Angular 14 peers) andnpm run buildcompiles the production bundles. Copyingpackage*.jsonbefore the source keeps the install layer cached. -
Stage 2: serve with nginx. A lightweight nginx:alpine image serves the compiled output; the custom
nginx.confadds 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: bridgeExplanation:
-
Frontend service: builds the Angular image from its Dockerfile. Note its
portsentry is commented out: the frontend is not published to the host, and traffic reaches it only through the nginx load balancer. -
API service: builds the .NET Core API image and takes its connection string and secrets from environment variables (read from the
.envfile at the project root). Like the frontend, it publishes no host port; it listens on 8000 inside the network. -
mssql service: pulls the official MS SQL Server 2022 image, sets the SA password via
MSSQL_SA_PASSWORD, persists data in themssql_datavolume, 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. -
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.
-
Network: all four services join the same bridge network, mssql_network, which is what lets the API reach the database as
mssqlby 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_networkNow, 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:
- Build and start the stack:
docker-compose up --buildThis command builds the .NET Core API and Angular images, pulls the MS SQL Server image, and starts all containers connected via the Docker network.
-
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. -
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
.envfile.
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: bridgeIn 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.