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
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:#
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 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:#
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.
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_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.
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, notlocalhost. depends_onandsleep 15sare 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.
