Docker Build Optimization Implementation Plan#
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Eliminate all Docker build bottlenecks so NuGet layer caching works correctly, the build context is small, and the dotnet-build stage is shared across all bake targets.
Architecture: Fix the root Dockerfile to copy .csproj files first with BuildKit cache mounts before copying source, update .dockerignore to exclude non-build directories, restructure docker-bake.hcl to define a shared dotnet-build target referenced by all final-image targets, and add a persistent NuGet cache volume to compose.dev.yaml.
Tech Stack: Docker BuildKit, .NET 10, docker-bake HCL, Docker Compose Watch
File Map#
| File | Change |
|---|---|
Dockerfile |
Two-phase COPY in development + build stages; BuildKit cache mounts; remove --no-cache; copy nuget.config + Directory.Packages.props |
.dockerignore |
Add **/bin, **/obj, ClientApps/, docs/, infra/, scripts/, .ai/, .claude/, .worktrees/, *.md |
docker-bake.hcl |
Add shared dotnet-build target; add contexts to final-image targets; add cache-from/cache-to; remove no-cache = true |
compose.dev.yaml |
Add nuget-packages named volume; mount it in all three backend dev services |
Task 1: Fix .dockerignore — Reduce Build Context Size#
Files:
- Modify: .dockerignore
This is done first so all subsequent Docker builds benefit from the smaller context.
- [ ] Step 1: Replace the root
.dockerignorecontent
Replace the entire file with:
.git
.github
.rabbitmq
.gitlab
.vs
.vscode
elastic_data
node_modules
build
dist
.env
.npmrc
artifacts
# Build artifacts — prevent bin/obj from inflating build context
**/bin
**/obj
# Frontend has its own Dockerfile + build context; not needed here
ClientApps/
# Documentation and non-build dirs — not needed in Docker build
docs/
mkdocs.yml
Dockerfile.mkdocs
infra/
scripts/
.ai/
.claude/
.worktrees/
# Markdown files are not needed in the build
*.md
- [ ] Step 2: Verify the file looks correct
Run:
Expected: File ends with *.md and contains **/bin, **/obj, ClientApps/.
- [ ] Step 3: Commit
git add .dockerignore
git commit -m "chore: reduce Docker build context — exclude bin/obj/ClientApps/docs"
Task 2: Fix Dockerfile — Development Stage#
Files:
- Modify: Dockerfile (lines 20-44, the development stage)
The development stage is used by compose.dev.yaml. Fix it so a .cs change doesn't invalidate the restore cache.
- [ ] Step 1: Replace the
developmentstage body
Replace lines 20–44 of Dockerfile (the entire development stage from FROM through the # No need to run here comment) with:
# --- Development Stage (for dotnet watch) ---
FROM ${BASE_IMAGE_SDK} AS development
ARG BASE_IMAGE_SDK # Re-declare ARG for this stage if not using directly
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
ENV DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1
ENV DOTNET_WATCH_HOST_OS=Unix
WORKDIR /src
# Phase 1: Copy dependency manifests only (cached layer — invalidated only when .csproj files change)
COPY CommandCenter.sln .
COPY nuget.config .
COPY Directory.Packages.props .
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 (NuGet packages persist across builds)
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet restore CommandCenter.sln
# Phase 3: Copy source (this layer invalidates on code changes, but restore is already cached)
COPY backend backend/
# No need to run here; docker-compose.yaml will specify the command (dotnet watch run)
# and set the working directory for each service.
- [ ] Step 2: Verify the development stage structure
Expected: The development stage shows COPY *.csproj lines before the RUN dotnet restore line, and COPY backend backend/ comes after restore.
- [ ] Step 3: Commit
Task 3: Fix Dockerfile — Build Stage#
Files:
- Modify: Dockerfile (lines 46–67, the build stage)
The build stage is used for production images. Apply the same two-phase pattern and remove the --no-cache flag.
- [ ] Step 1: Replace the
buildstage body
Replace lines 46–67 of Dockerfile (the entire build stage from FROM through the dotnet publish line) with:
# --- Build Stage (for production) ---
FROM ${BASE_IMAGE_SDK} AS build
ARG BASE_IMAGE_SDK # Re-declare ARG for this stage if not using directly
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
ENV DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1
WORKDIR /src
# Phase 1: Copy dependency manifests only (cached layer — invalidated only when .csproj files change)
COPY CommandCenter.sln .
COPY nuget.config .
COPY Directory.Packages.props .
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 (no --no-cache: let NuGet use its HTTP cache)
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet restore CommandCenter.sln
# Phase 3: Copy source and build
COPY backend backend/
# Build the entire solution
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet build CommandCenter.sln -c Release --no-restore
# Publish each application separately
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet publish CommandCenter.sln --use-current-runtime -c Release --artifacts-path /app/publish -bl -p ErrorOnDuplicatePublishOutputFiles=false
- [ ] Step 2: Verify the build stage
Expected: No --no-cache flag anywhere. Both stages show COPY *.csproj before RUN dotnet restore.
- [ ] Step 3: Commit
git add Dockerfile
git commit -m "perf: fix NuGet layer caching in Dockerfile build stage — remove --no-cache"
Task 4: Restructure docker-bake.hcl — Shared Build Target#
Files:
- Modify: docker-bake.hcl
Add a shared dotnet-build target and wire the final-image targets to reference it. Remove no-cache = true from the commandcenter target.
- [ ] Step 1: Replace the entire
docker-bake.hclcontent
// Define common variables for image versions
variable "DOTNET_VERSION" {
default = "10.0"
}
variable "NODE_VERSION" {
default = "22.16"
}
// Function to generate .NET build args
function "dotnet_args" {
params = []
result = {
BASE_IMAGE_ASPNET = "mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}"
BASE_IMAGE_RUNTIME = "mcr.microsoft.com/dotnet/runtime:${DOTNET_VERSION}"
BASE_IMAGE_SDK = "mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}"
BASE_IMAGE_SELF_CONTAINED = "mcr.microsoft.com/dotnet/runtime-deps:${DOTNET_VERSION}"
}
}
group "default" {
targets = [
"commandcenter",
"commandcenter-bff-frontdoor",
"commandcenter-frontend",
"commandcenter-webapi",
"docs"
]
}
// Shared .NET solution build stage — builds and publishes the entire solution once.
// Final image targets reference this via `contexts` so the solution is never built twice.
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"]
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-frontend" {
context = "ClientApps\\command-center"
dockerfile = "Dockerfile"
args = {
NODE_VERSION = NODE_VERSION
}
target = "production"
tags = ["cc-ui: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"]
}
target "docs" {
context = "."
dockerfile = "Dockerfile.mkdocs"
tags = ["my-mkdocks-material"]
output = ["type=docker,load=true,push=false"]
}
- [ ] Step 2: Verify
no-cacheis removed anddotnet-buildtarget exists
Expected: no-cache does not appear. dotnet-build appears once as a target name. contexts appears three times (in commandcenter, commandcenter-bff-frontdoor, commandcenter-webapi).
- [ ] Step 3: Commit
git add docker-bake.hcl
git commit -m "perf: add shared dotnet-build bake target with local cache-from/cache-to"
Task 5: Fix compose.dev.yaml — Persistent NuGet Cache Volume#
Files:
- Modify: compose.dev.yaml
Add a named nuget-packages volume and mount it in all three backend dev services so NuGet packages persist across container rebuilds.
- [ ] Step 1: Replace the entire
compose.dev.yamlcontent
volumes:
pnpm-store:
external: true
name: pnpm-store
nuget-packages:
name: nuget-packages
services:
commandcenter-frontend:
build:
context: ClientApps/command-center
dockerfile: Dockerfile
target: build-stage
command: pnpm run dev -- --host 0.0.0.0
develop:
watch:
- action: sync
path: ./ClientApps/command-center
target: /app
ignore:
- node_modules/
- action: rebuild
path: ClientApps/command-center/package.json
volumes:
- pnpm-store:/pnpm/store
commandcenter-bff-frontdoor:
build:
target: development
args:
- BUILD_CONFIGURATION=Debug
working_dir: /src/backend/CommandCenter.Bff.Frontdoor
command: dotnet watch run --urls=http://0.0.0.0:80 --no-launch-profile
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ASPNETCORE_URLS=http://+:80
- HTTP_PORTS=80
volumes:
- ${APPDATA}/Microsoft/UserSecrets:/root/.microsoft/usersecrets:ro
- ${APPDATA}/Microsoft/UserSecrets:/home/app/.microsoft/usersecrets:ro
- ${APPDATA}/ASP.NET/Https:/root/.aspnet/https:ro
- nuget-packages:/root/.nuget/packages
develop:
watch:
- action: sync
path: ./backend
target: /src/backend
- action: sync
path: ./CommandCenter.sln
target: /src/CommandCenter.sln
commandcenter:
build:
target: development
args:
- BUILD_CONFIGURATION=Debug
working_dir: /src/backend/CommandCenter
command: dotnet watch run --urls=http://0.0.0.0:80
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ASPNETCORE_URLS=http://+:80
- HTTP_PORTS=80
volumes:
- ${APPDATA}/Microsoft/UserSecrets:/root/.microsoft/usersecrets:ro
- ${APPDATA}/Microsoft/UserSecrets:/home/app/.microsoft/usersecrets:ro
- ${APPDATA}/ASP.NET/Https:/root/.aspnet/https:ro
- nuget-packages:/root/.nuget/packages
develop:
watch:
- action: sync
path: ./backend
target: /src/backend
- action: sync
path: ./CommandCenter.sln
target: /src/CommandCenter.sln
commandcenter-webapi:
build:
target: development
args:
- BUILD_CONFIGURATION=Debug
working_dir: /src/backend/CommandCenter.WebApi
command: dotnet watch run --urls=http://0.0.0.0:80
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ASPNETCORE_URLS=http://+:80
- HTTP_PORTS=80
volumes:
- ${APPDATA}/Microsoft/UserSecrets:/root/.microsoft/usersecrets:ro
- ${APPDATA}/Microsoft/UserSecrets:/home/app/.microsoft/usersecrets:ro
- ${APPDATA}/ASP.NET/Https:/root/.aspnet/https:ro
- nuget-packages:/root/.nuget/packages
develop:
watch:
- action: sync
path: ./backend
target: /src/backend
- action: sync
path: ./CommandCenter.sln
target: /src/CommandCenter.sln
docs:
develop:
watch:
- action: rebuild
path: mkdocs.yml
- action: rebuild
path: Dockerfile.mkdocs
- [ ] Step 2: Verify volume appears in all three services
Expected: nuget-packages appears 4 times — once in the volumes: block definition and once per backend service.
- [ ] Step 3: Commit
git add compose.dev.yaml
git commit -m "perf: add persistent NuGet cache volume to dev compose services"
Task 6: Verify End-to-End#
These steps confirm all optimizations work together.
- [ ] Step 1: Check build context size
Or trigger a real build and watch the "sending build context" line in the output. With the new .dockerignore, it should be a fraction of the original size (no bin/, obj/, ClientApps/, docs/ in the context).
- [ ] Step 2: Build once to warm the cache
Note the time taken for dotnet restore.
- [ ] Step 3: Touch a
.csfile and rebuild — verify restore is cached
# Touch any C# file in a backend project
touch backend/CommandCenter/Program.cs
# Rebuild
docker compose --env-file .env -f compose.yaml -f compose.dev.yaml build
Expected: The dotnet restore step shows CACHED — packages are NOT re-downloaded. Only the COPY backend backend/ layer and subsequent build/publish layers re-run.
- [ ] Step 4: Test bake shared build target
Expected: The dotnet-build target runs once in the output. Both final images are produced without duplicating the solution build.
- [ ] Step 5: Start dev environment and verify hot reload still works
Expected: All three backend services start with dotnet watch run. Make a small change to a .cs file (e.g., add a comment to backend/CommandCenter/Program.cs) — the affected service should hot-reload within a few seconds.
- [ ] Step 6: Verify nuget-packages volume is populated
docker volume inspect nuget-packages
docker run --rm -v nuget-packages:/packages alpine ls /packages
Expected: The volume exists and contains NuGet package directories (e.g., microsoft.aspnetcore.*, system.*).
- [ ] Step 7: Commit the plan + spec to git
git add docs/superpowers/specs/2026-04-09-docker-build-optimization-design.md
git add docs/superpowers/plans/2026-04-09-docker-build-optimization.md
git commit -m "docs: add Docker build optimization spec and implementation plan"
Self-Review#
Spec coverage check:
| Spec requirement | Task |
|---|---|
Fix NuGet layer caching (.csproj-first + BuildKit cache mounts) |
Task 2 (dev stage) + Task 3 (build stage) |
Remove --no-cache from dotnet restore |
Task 3, Step 1 |
Fix .dockerignore — add **/bin, **/obj, ClientApps/ |
Task 1 |
Restructure docker-bake.hcl with shared dotnet-build + cache-from/cache-to |
Task 4 |
Remove no-cache = true from commandcenter bake target |
Task 4, Step 1 |
Add NuGet cache volume to compose.dev.yaml |
Task 5 |
Copy nuget.config and Directory.Packages.props into build |
Task 2 + Task 3 (both COPY nuget.config . and COPY Directory.Packages.props . are explicit in the stage bodies) |
All spec requirements are covered. No gaps found.