Docker: Packaging Applications with Their Dependencies

A practical guide to Docker — the container platform that packages an application together with everything it needs to run, covering images and containers, Dockerfile best practices, multi-stage builds, networking, volumes, Docker Compose, and how containers fit into the deployment platforms covered elsewhere in this series.


Table of Contents

  1. Introduction
  2. Images and Containers
  3. Writing a Dockerfile
  4. Multi-Stage Builds
  5. Layers and Caching
  6. Image Size Optimization
  7. Networking
  8. Volumes and Persistent Data
  9. Docker Compose
  10. Environment Configuration and Secrets
  11. Health Checks
  12. Security Practices
  13. Where Docker Fits: Local Dev to Production
  14. Quick Reference Table
  15. Conclusion

Introduction

Docker packages an application together with its runtime, libraries, and configuration into a single, portable unit — a container — that runs identically regardless of what's installed on the underlying host machine. The pitch that made Docker ubiquitous is simple: "it works on my machine" stops being a meaningful excuse, because the container carries its entire runtime environment with it, from a developer's laptop through CI to production.

FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY publish/ .
ENTRYPOINT ["dotnet", "MyApi.dll"]

Enter fullscreen mode Exit fullscreen mode

docker build -t my-api .
docker run -p 8080:8080 my-api

Enter fullscreen mode Exit fullscreen mode

Four lines of Dockerfile and two commands produce a runnable, portable artifact — the same image runs unchanged on a developer's laptop, a CI runner, or the container orchestration platforms covered elsewhere in this series (AKS, ECS, Kubernetes generally).


1. Images and Containers

The distinction that trips up newcomers

An image is a read-only template — a snapshot of a filesystem plus metadata (what command to run, what ports to expose) — built once and stored, shared, and versioned. A container is a running (or stopped) instance of an image, with its own writable layer on top and its own process, network namespace, and lifecycle.

docker build -t my-api:1.0 .        # builds an image from a Dockerfile
docker run my-api:1.0                # starts a container from that image
docker ps                            # lists running containers
docker stop <container-id>           # stops a running container (the image is untouched)

Enter fullscreen mode Exit fullscreen mode

The relationship is the same as a class and an object in object-oriented programming: one image, many containers can be started from it, each an independent running instance with its own state, and stopping/removing a container never affects the image it came from.

Registries: sharing images

docker tag my-api:1.0 myregistry.azurecr.io/my-api:1.0
docker push myregistry.azurecr.io/my-api:1.0

docker pull myregistry.azurecr.io/my-api:1.0

Enter fullscreen mode Exit fullscreen mode

A registry (Docker Hub, Azure Container Registry, Amazon ECR, GitHub Container Registry) stores and distributes images — docker push/docker pull move an image to/from a registry, the same mechanism used by the Azure Compute and AWS Compute guides in this series to get a container image from a build pipeline into ECS or AKS.


2. Writing a Dockerfile

A Dockerfile for an ASP.NET Core application

FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 8080

FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["MyApi.csproj", "."]
RUN dotnet restore "MyApi.csproj"
COPY . .
RUN dotnet build "MyApi.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "MyApi.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]

Enter fullscreen mode Exit fullscreen mode

Key instructions

  • FROM — the base image everything else builds on top of; choosing the right base image (Section 5) matters enormously for size and security.
  • WORKDIR — sets the working directory for subsequent instructions, avoiding the need for absolute paths everywhere.
  • COPY — copies files from the build context (the directory docker build is run from) into the image.
  • RUN — executes a command during the build, its result baked into the resulting image layer.
  • ENTRYPOINT/CMD — defines what runs when a container starts from this image; ENTRYPOINT is generally preferred for the main process, with CMD reserved for default arguments that can be overridden at docker run time.
  • EXPOSE — documents which port the containerized application listens on (informational — it doesn't actually publish the port; that's done via docker run -p).

.dockerignore

bin/
obj/
.git/
**/node_modules/
*.md

Enter fullscreen mode Exit fullscreen mode

Just like .gitignore, a .dockerignore file excludes files from the build context sent to the Docker daemon — without it, COPY . . can accidentally include huge, irrelevant directories (bin/, obj/, .git/), slowing builds and bloating the resulting image unnecessarily.


3. Multi-Stage Builds

The problem multi-stage builds solve

Building a .NET application requires the full SDK (compilers, build tools, NuGet caches) — but running the built application only requires the much smaller ASP.NET Core runtime. Without multi-stage builds, you'd either need to install the full SDK in your production image (bloating it unnecessarily) or manage a separate, more complex build process outside Docker entirely.

# Stage 1: build with the full SDK (large, but only exists during the build)
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /app/publish

# Stage 2: run with just the runtime (much smaller)
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]

Enter fullscreen mode Exit fullscreen mode

The COPY --from=build instruction pulls only the compiled output from the build stage into the final image — the SDK, source code, and any intermediate build artifacts never make it into the image that actually ships, dramatically reducing the final image's size and attack surface (fewer tools available inside the running container means less an attacker could potentially exploit if they gained access).

Named stages for clarity and reuse

FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
# ... build steps ...

FROM build AS test
RUN dotnet test --logger trx

FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
COPY --from=build /app/publish .

Enter fullscreen mode Exit fullscreen mode

Naming stages (AS build, AS test, AS final) lets a single Dockerfile express multiple related purposes — a CI pipeline might specifically target the test stage (docker build --target test) to run tests inside a container matching the production environment exactly, while the default build (no --target) produces the final, minimal runtime image.


4. Layers and Caching

Every instruction creates a layer

FROM mcr.microsoft.com/dotnet/sdk:9.0    # layer 1
WORKDIR /src                              # layer 2
COPY MyApi.csproj .                       # layer 3
RUN dotnet restore                        # layer 4
COPY . .                                   # layer 5
RUN dotnet build                          # layer 6

Enter fullscreen mode Exit fullscreen mode

Each instruction produces a distinct, cached filesystem layer — Docker reuses a cached layer for any instruction whose inputs haven't changed since the last build, only re-running instructions from the first point of actual change onward.

Ordering instructions for effective caching

# ✅ Good: dependencies restored before copying all source code
COPY MyApi.csproj .
RUN dotnet restore
COPY . .
RUN dotnet build

# ❌ Wasteful: copying everything first means any source change invalidates the restore cache too
COPY . .
RUN dotnet restore
RUN dotnet build

Enter fullscreen mode Exit fullscreen mode

Copying just the project file and restoring dependencies before copying the rest of the source code means that changing application code (which happens far more often than changing dependencies) doesn't invalidate the expensive dotnet restore layer — Docker can reuse the cached restore layer and only re-run the build step, meaningfully speeding up iterative local builds and CI runs alike.


5. Image Size Optimization

Choosing the right base image

Base image family Size Trade-off
sdk Largest Full build toolchain — only needed during the build stage, never in the final image
aspnet/dotnet (runtime) Medium Everything needed to run a published app, nothing more
aspnet:9.0-alpine Smaller Musl libc-based, smaller footprint, occasional compatibility quirks with some native dependencies
chiseled (Ubuntu Chiseled, .NET 8+) Smallest Minimal, distroless-style image — no shell, no package manager, drastically reduced attack surface
FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled AS final

Enter fullscreen mode Exit fullscreen mode

Chiseled Ubuntu images (available for .NET 8+) strip out everything not strictly needed to run a .NET application — no shell, no package manager, no extraneous OS packages — producing both a smaller image and a meaningfully reduced attack surface, since there's simply less present in the container for an attacker to exploit even if they found a way in. This has become a commonly recommended default for production .NET container images where the extra debugging convenience of a full shell inside the container isn't a priority.

Minimizing layers and RUN commands

# ✅ Combines related commands into a single layer
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*

# ❌ Each RUN is a separate layer, and the cleanup in a later layer doesn't shrink earlier layers
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

Enter fullscreen mode Exit fullscreen mode

Because layers are additive and immutable once created, deleting a file in a later layer doesn't reduce the image's actual size — the file still physically exists in an earlier layer. Combining install-and-cleanup into a single RUN instruction (so the cleanup happens within the same layer as the installation) is the standard pattern for avoiding this trap.

Checking what's actually in an image

docker history my-api:1.0
docker images my-api:1.0

Enter fullscreen mode Exit fullscreen mode

docker history shows the size contributed by each layer — a quick way to spot an unexpectedly large layer (a forgotten cache directory, an unnecessarily broad COPY) worth investigating.


6. Networking

Default bridge networking and port publishing

docker run -p 8080:8080 my-api

Enter fullscreen mode Exit fullscreen mode

By default, a container runs in an isolated network namespace, reachable from the host only via explicitly published ports (-p host-port:container-port) — the container's internal port (8080, matching the EXPOSE in the Dockerfile) is mapped to a port on the host machine, which is what actually makes the containerized application reachable from outside.

Container-to-container communication via user-defined networks

docker network create my-app-network
docker run --network my-app-network --name api my-api
docker run --network my-app-network --name db postgres

Enter fullscreen mode Exit fullscreen mode

Containers on the same user-defined bridge network can reach each other by container name (api can connect to db:5432 directly) — Docker's embedded DNS resolves container names to their current IP addresses automatically, which is exactly the mechanism Docker Compose (Section 8) relies on under the hood to let services in a multi-container application find each other without hardcoded IPs.


7. Volumes and Persistent Data

The problem: containers are ephemeral by design

A container's writable layer is deleted when the container is removed — anything an application writes inside the container (a database's data files, uploaded content) is lost the moment that container is torn down, unless it's explicitly persisted outside the container's own lifecycle.

Named volumes

docker volume create pgdata
docker run -v pgdata:/var/lib/postgresql/data postgres

Enter fullscreen mode Exit fullscreen mode

A named volume is storage managed by Docker itself, living outside any single container's lifecycle — the volume persists even if the container using it is removed and recreated, and can be reattached to a new container (the standard pattern for "upgrade the database container's image without losing the actual data").

Bind mounts

docker run -v /host/path/config:/app/config my-api

Enter fullscreen mode Exit fullscreen mode

A bind mount maps a specific path on the host filesystem directly into the container — commonly used in local development to mount source code into a container for live-reload workflows, or to mount configuration files from the host without baking them into the image itself.

Choosing between them

  • Named volumes — the right choice for data a containerized application needs to persist (database files, uploaded content) in both development and production; Docker manages where the data actually lives.
  • Bind mounts — best for local development convenience (mounting source code for hot-reload) or mounting specific host configuration; less appropriate for production data persistence since they tie the container to a specific host filesystem layout.

8. Docker Compose

Defining a multi-container application declaratively

services:
  api:
    build: .
    ports:
      - "8080:8080"
    environment:
      - ConnectionStrings__Default=Host=db;Database=storedb;Username=postgres;Password=devpassword
    depends_on:
      - db
      - redis

  db:
    image: postgres:17
    environment:
      - POSTGRES_PASSWORD=devpassword
      - POSTGRES_DB=storedb
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7

volumes:
  pgdata:

Enter fullscreen mode Exit fullscreen mode

docker compose up
docker compose down

Enter fullscreen mode Exit fullscreen mode

Docker Compose defines an entire multi-container application — the API, its database, its cache — in one declarative YAML file, with docker compose up building/pulling and starting everything together, on a shared network, with dependency ordering (depends_on) and volume management handled automatically. This is the standard way to spin up a full local development environment (application plus its database plus its cache) with a single command, rather than manually running and networking several docker run commands by hand.

depends_on starts, but doesn't wait for readiness

services:
  api:
    depends_on:
      db:
        condition: service_healthy   # waits for the health check (Section 10), not just "container started"

Enter fullscreen mode Exit fullscreen mode

By default, depends_on only guarantees start order (the db container begins starting before api does) — it doesn't guarantee the database is actually ready to accept connections yet. Pairing depends_on with a condition: service_healthy check (relying on the dependency's health check, Section 10) is the correct way to actually wait for readiness, not just process start, avoiding a common source of "works most of the time, fails intermittently on a fresh startup" bugs.


9. Environment Configuration and Secrets

Environment variables

environment:
  - ASPNETCORE_ENVIRONMENT=Development
  - ConnectionStrings__Default=${DB_CONNECTION_STRING}

Enter fullscreen mode Exit fullscreen mode

docker run -e ASPNETCORE_ENVIRONMENT=Production my-api

Enter fullscreen mode Exit fullscreen mode

Environment variables are the standard way to inject configuration into a container without rebuilding the image for each environment — ASP.NET Core's configuration system (covered in this series' ASP.NET Core guide) reads these automatically, including the __ double-underscore convention for nested configuration keys.

.env files for local development

# .env
DB_CONNECTION_STRING=Host=db;Database=storedb;Username=postgres;Password=devpassword

Enter fullscreen mode Exit fullscreen mode

Docker Compose automatically reads a .env file in the same directory, substituting ${VARIABLE} references in the Compose file — convenient for local development, but this file should never be committed to source control if it contains real secrets, and it's not an appropriate mechanism for production secret management (see below).

Secrets in production: don't bake them into the image, don't rely on plain environment variables alone

# ❌ Never do this — the secret becomes part of the image's layer history permanently, even if a later layer "removes" it
RUN echo "API_KEY=secret123" > /app/.env

Enter fullscreen mode Exit fullscreen mode

Baking a secret into an image layer means it's retrievable by anyone who can pull that image, even after a "later" instruction appears to remove it (layers are additive, as covered in Section 5). Production secret management should instead flow through the orchestration platform's own mechanism — Kubernetes/AKS Secrets (ideally backed by a proper secrets store like Azure Key Vault via the Secrets Store CSI Driver, as covered in this series' Azure Compute guide), AWS ECS task definitions pulling from AWS Secrets Manager, or an equivalent — injected into the container at runtime rather than baked in at build time.


10. Health Checks

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD curl -f http://localhost:8080/health || exit 1

Enter fullscreen mode Exit fullscreen mode

docker ps   # STATUS column shows "healthy" / "unhealthy" once checks have run

Enter fullscreen mode Exit fullscreen mode

A HEALTHCHECK instruction tells Docker how to determine whether a running container is actually functioning correctly, not just whether its process happens to still be alive — orchestration platforms (Kubernetes, ECS, Docker Compose's condition: service_healthy) rely on this signal to decide whether to route traffic to a container, restart it, or hold off starting a dependent service, connecting directly to the health check patterns covered in this series' Background Services and ASP.NET Core guides (/health endpoints via AddHealthChecks()).


11. Security Practices

Run as a non-root user

FROM mcr.microsoft.com/dotnet/aspnet:9.0
# The official .NET images already include a non-root 'app' user
USER app
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]

Enter fullscreen mode Exit fullscreen mode

Running a container's main process as root (the default in many base images unless explicitly changed) means that a container-escape vulnerability could grant an attacker root access on the underlying host — running as a dedicated non-root user (Microsoft's official .NET images provide one out of the box) is a meaningful, low-effort defense-in-depth measure.

Scan images for known vulnerabilities

docker scout cves my-api:1.0

Enter fullscreen mode Exit fullscreen mode

Both Docker itself (via Docker Scout) and third-party tools (Trivy, Snyk, Grype) can scan an image's layers against known-vulnerability databases (CVEs) for the OS packages and application dependencies baked into it — integrating this into CI (failing a build if a critical vulnerability is found in the base image or a dependency) catches a real, ongoing class of risk that doesn't go away just because a Dockerfile "builds successfully."

Keep base images updated

FROM mcr.microsoft.com/dotnet/aspnet:9.0   # a floating tag that receives security patches over time

Enter fullscreen mode Exit fullscreen mode

Rebuilding and redeploying periodically (even without any application code change) to pick up base-image security patches is a genuine, ongoing operational responsibility — an image built once and never rebuilt slowly accumulates unpatched vulnerabilities in its OS layer as time passes, even if the application code itself hasn't changed at all.

Minimize what's inside the final image

As covered in Sections 3 and 5, multi-stage builds and minimal/chiseled base images both serve security as much as size — less software present inside a running container (no SDK, no shell, no package manager) means less an attacker can leverage even after gaining some level of access.


12. Where Docker Fits: Local Dev to Production

Local development

docker compose up

Enter fullscreen mode Exit fullscreen mode

The most immediate, widely felt benefit — a new developer joining a project runs one command and gets a fully working local environment (application, database, cache, any other dependencies) without manually installing and configuring each piece directly on their machine, and without "works on my machine" discrepancies between team members' setups.

CI

# GitHub Actions example
- name: Build Docker image
  run: docker build -t my-api:${{ github.sha }} .
- name: Run tests inside the build
  run: docker build --target test .

Enter fullscreen mode Exit fullscreen mode

CI pipelines (covered in this series' GitHub Actions and Azure DevOps guides) commonly build the exact same Dockerfile that will eventually run in production, ensuring the tested artifact and the deployed artifact are genuinely identical — not just "built from the same source code," but the literal same image, layer for layer.

Production: containers as the deployment unit

Every container orchestration platform covered elsewhere in this series ultimately runs Docker (or an OCI-compatible equivalent) images as its fundamental deployment unit:

  • AKS/ECS/Kubernetes generally — schedule and run container images directly, exactly as covered in this series' Azure Compute and AWS Compute guides.
  • Azure Container Apps — a higher-level, more managed layer specifically for running containers without directly operating Kubernetes.
  • AWS Lambda (container image support) — even serverless functions can be packaged as container images, as covered in this series' AWS Compute guide.

The core insight tying this all together: once an application is packaged as a Docker image, where it ultimately runs becomes largely an infrastructure/deployment decision rather than something requiring the application itself to change — the same image that runs via docker run on a laptop is, with the right orchestration configuration, the same image running at scale in production.


Quick Reference Table

Concept Purpose
Image vs. container Read-only template vs. a running (or stopped) instance of it
Dockerfile Instructions for building an image
Multi-stage build Separates build-time tooling from the final runtime image
Layer caching Reuses unchanged instruction results across builds for speed
.dockerignore Excludes files from the build context
Named volume Docker-managed persistent storage, independent of container lifecycle
Bind mount Direct host-filesystem mapping into a container
Docker Compose Declarative multi-container application definition
HEALTHCHECK Signals actual application readiness/liveness to orchestrators
Non-root USER Reduces impact of a container-escape vulnerability
Chiseled/distroless base images Minimal attack surface, smallest final image size
Image scanning Detects known vulnerabilities (CVEs) in an image's layers

Conclusion

Docker's lasting impact comes from a genuinely simple idea executed well: package an application with everything it needs to run, as a single portable artifact, and let that artifact move unchanged from a developer's laptop through CI and into production. The practical skill of using Docker well is mostly about a handful of well-understood disciplines — structuring a Dockerfile for effective layer caching, using multi-stage builds to keep the shipped image lean, being deliberate about persistent data via volumes, and treating container security (non-root users, minimal base images, vulnerability scanning) as a first-class concern rather than an afterthought.

Everything covered elsewhere in this series about deploying to AKS, ECS, or any other container platform ultimately builds on the concepts in this guide — the container is the unit those platforms schedule, scale, and network, and understanding how it's actually built and what's actually inside it is what makes troubleshooting a production deployment problem tractable rather than mysterious.


Found this useful? Feel free to star the repo, open an issue with corrections, or share the layer-ordering fix that cut your build time down dramatically.