Skip to content

Docker Build Optimization for Fast Local Development#

Date: 2026-04-09 Status: Draft Scope: Both local dev (compose watch) and production builds

Context#

The Docker build setup for CommandCenter has accumulated several performance bottlenecks that make the initial docker compose watch startup slow and production builds unnecessarily expensive. The root cause is that NuGet layer caching is completely broken — any .cs file change invalidates the restore cache, triggering a full package download. Combined with explicit no-cache flags and an oversized build context, builds take far longer than necessary.

Current Architecture#

  • Root Dockerfile: Multi-stage build producing 3 final images (bff-final, commandcenter-final, webapi-final) plus a development stage for dotnet watch
  • docker-bake.hcl: BuildKit bake file defining targets for each service
  • compose.yaml + compose.dev.yaml: Docker Compose with Watch mode for local dev, using dotnet watch run inside containers
  • ClientApps/command-center/Dockerfile: Separate frontend build (already well-optimized with pnpm cache mounts)

Identified Bottlenecks#

Priority Issue Impact
P0 COPY backend backend/ before restore Any .cs change triggers full NuGet restore
P0 no-cache = true on commandcenter bake target All layers rebuilt from scratch
P0 dotnet restore --no-cache in build stage Bypasses NuGet HTTP cache
P1 No cache-from/cache-to in docker-bake.hcl CI builds start cold
P1 Root .dockerignore missing **/bin, **/obj, ClientApps/ Oversized build context
P2 nuget.config not copied into Docker build Potential silent config drift
P2 No NuGet cache volume for dev containers Rebuilds re-download packages
P3 No BuildKit --mount=type=cache for NuGet Backend misses cache mount pattern frontend uses

Design#

1. Dockerfile — NuGet Layer Caching Fix#

Files: Dockerfile

Replace the bulk COPY backend backend/ with a two-phase copy in both development and build stages.

Phase 1 — Dependency manifests only (cacheable layer):

COPY CommandCenter.sln .
COPY nuget.config .
COPY Directory.Packages.props .

# Copy only .csproj files, preserving directory structure
COPY backend/CommandCenter/CommandCenter.csproj backend/CommandCenter/
COPY backend/CommandCenter.AzureAd/CommandCenter.AzureAd.csproj backend/CommandCenter.AzureAd/
COPY backend/CommandCenter.Benefits/CommandCenter.Benefits.csproj backend/CommandCenter.Benefits/
COPY backend/CommandCenter.BenefitsAgent/CommandCenter.BenefitsAgent.csproj backend/CommandCenter.BenefitsAgent/
COPY backend/CommandCenter.BenefitsReverseProxy/CommandCenter.BenefitsReverseProxy.csproj backend/CommandCenter.BenefitsReverseProxy/
COPY backend/CommandCenter.Bff.Frontdoor/CommandCenter.Bff.Frontdoor.csproj backend/CommandCenter.Bff.Frontdoor/
COPY backend/CommandCenter.CodeAnalysis.Analyzers/CommandCenter.CodeAnalysis.Analyzers.csproj backend/CommandCenter.CodeAnalysis.Analyzers/
COPY backend/CommandCenter.DataRetentionWorker/CommandCenter.DataRetentionWorker.csproj backend/CommandCenter.DataRetentionWorker/
COPY backend/CommandCenter.DiagnosticApi/CommandCenter.DiagnosticApi.csproj backend/CommandCenter.DiagnosticApi/
COPY backend/CommandCenter.Keyplex/CommandCenter.Keyplex.csproj backend/CommandCenter.Keyplex/
COPY backend/CommandCenter.QueryDom/CommandCenter.QueryDom.csproj backend/CommandCenter.QueryDom/
COPY backend/CommandCenter.QueryDom.UnitTests/CommandCenter.QueryDom.UnitTests.csproj backend/CommandCenter.QueryDom.UnitTests/
COPY backend/CommandCenter.ServerAgent/CommandCenter.DmzAgent.csproj backend/CommandCenter.ServerAgent/
COPY backend/CommandCenter.SystemStatsAPI/CommandCenter.SystemStatsAPI.csproj backend/CommandCenter.SystemStatsAPI/
COPY backend/CommandCenter.Test/CommandCenter.Test.csproj backend/CommandCenter.Test/
COPY backend/CommandCenter.WebApi/CommandCenter.WebApi.csproj backend/CommandCenter.WebApi/
COPY backend/CommandCenter.WorkerService/CommandCenter.WorkerService.csproj backend/CommandCenter.WorkerService/
COPY backend/Shared/Shared.csproj backend/Shared/
COPY backend/Shared.Logging/Shared.Logging.csproj backend/Shared.Logging/
COPY backend/Shared.MessageBroker/Shared.MessageBroker.csproj backend/Shared.MessageBroker/
COPY backend/Shared.Tracing/Shared.Tracing.csproj backend/Shared.Tracing/

Phase 2 — Restore with BuildKit cache mount:

RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
    dotnet restore CommandCenter.sln

Phase 3 — Source code (only this layer invalidates on code changes):

COPY backend backend/

Additional changes to the build stage: - Remove --no-cache from dotnet restore CommandCenter.sln --no-cache (line 61) - Add --mount=type=cache to the build and publish RUN commands as well

2. .dockerignore Fixes#

File: .dockerignore

Add these exclusions to reduce build context size:

# Build artifacts
**/bin
**/obj

# Frontend (has its own Dockerfile with its own context)
ClientApps/

# Documentation
docs/
mkdocs.yml
Dockerfile.mkdocs

# Non-build directories
infra/
scripts/
.ai/
.claude/
.worktrees/

# Misc
*.md
!Directory.Packages.props

Note: *.md excludes markdown files but Directory.Packages.props is not affected. The nuget.config is not a .md file so it's also safe.

3. docker-bake.hcl — Shared Build Target + Cache Strategy#

File: docker-bake.hcl

Restructure to define a shared dotnet-build target that other targets reference:

target "dotnet-build" {
    context    = "."
    dockerfile = "Dockerfile"
    target     = "build"
    args       = dotnet_args()
    cache-from = ["type=local,src=.buildx-cache"]
    cache-to   = ["type=local,dest=.buildx-cache,mode=max"]
}

target "commandcenter" {
    contexts   = { build = "target:dotnet-build" }
    context    = "."
    dockerfile = "Dockerfile"
    args       = dotnet_args()
    target     = "commandcenter-final"
    tags       = ["cc-api:cache"]
    # REMOVED: no-cache = true
    output     = ["type=docker,load=true,push=false"]
}

target "commandcenter-bff-frontdoor" {
    contexts   = { build = "target:dotnet-build" }
    context    = "."
    dockerfile = "Dockerfile"
    args       = dotnet_args()
    target     = "bff-final"
    tags       = ["cc-bff:cache"]
    output     = ["type=docker,load=true,push=false"]
}

target "commandcenter-webapi" {
    contexts   = { build = "target:dotnet-build" }
    context    = "."
    dockerfile = "Dockerfile"
    args       = dotnet_args()
    target     = "webapi-final"
    tags       = ["commandcenter-webapi:cache"]
    output     = ["type=docker,load=true,push=false"]
}

Key changes: - Remove no-cache = true from commandcenter target - Add cache-from/cache-to with local cache (switchable to registry for CI) - Shared dotnet-build target — solution built once, final targets reference via contexts

4. compose.dev.yaml — NuGet Cache Volume#

File: compose.dev.yaml

Add a persistent NuGet package cache volume:

volumes:
    pnpm-store:
        external: true
        name: pnpm-store
    nuget-packages:
        name: nuget-packages

services:
    commandcenter-bff-frontdoor:
        volumes:
            # existing mounts...
            - nuget-packages:/root/.nuget/packages

    commandcenter:
        volumes:
            # existing mounts...
            - nuget-packages:/root/.nuget/packages

    commandcenter-webapi:
        volumes:
            # existing mounts...
            - nuget-packages:/root/.nuget/packages

5. Dockerfile — Copy nuget.config and Directory.Packages.props#

File: Dockerfile

In both development and build stages, explicitly copy these files before restore:

COPY nuget.config .
COPY Directory.Packages.props .

These are already included in the Phase 1 copy block in Section 1 above.

Out of Scope#

  • SystemStatsAPI: Runs natively on Windows as a service, not in Docker
  • BenefitsReverseProxy / WorkerService: Have their own standalone Dockerfiles — not part of this optimization
  • Frontend Dockerfile: Already well-optimized with pnpm cache mounts
  • CI/CD pipeline changes: Cache strategy is local-first; registry-based caching can be wired up later

Verification#

  1. Build context size: Run docker compose build and observe the "sending build context" line — should be significantly smaller after .dockerignore fixes
  2. Layer caching: Build once, change a .cs file, build again — the restore layer should be cached (no "Restoring packages" output)
  3. Bake shared build: Run docker buildx bake commandcenter-bff-frontdoor commandcenter-webapi — the dotnet-build stage should execute once
  4. Dev startup: Run docker compose --env-file .env -f compose.yaml -f compose.dev.yaml watch — initial startup should be faster, and the NuGet cache volume should persist across rebuilds
  5. Existing functionality: All three services should start, respond to health checks, and dotnet watch should still hot-reload on code changes