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 adevelopmentstage fordotnet watch docker-bake.hcl: BuildKit bake file defining targets for each servicecompose.yaml+compose.dev.yaml: Docker Compose with Watch mode for local dev, usingdotnet watch runinside containersClientApps/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:
Phase 3 — Source code (only this layer invalidates on code changes):
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:
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#
- Build context size: Run
docker compose buildand observe the "sending build context" line — should be significantly smaller after.dockerignorefixes - Layer caching: Build once, change a
.csfile, build again — the restore layer should be cached (no "Restoring packages" output) - Bake shared build: Run
docker buildx bake commandcenter-bff-frontdoor commandcenter-webapi— thedotnet-buildstage should execute once - 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 - Existing functionality: All three services should start, respond to health checks, and
dotnet watchshould still hot-reload on code changes