Skip to content

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 .dockerignore content

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:

cat .dockerignore

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 development stage 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
grep -n "FROM\|COPY\|RUN\|WORKDIR" Dockerfile | head -40

Expected: The development stage shows COPY *.csproj lines before the RUN dotnet restore line, and COPY backend backend/ comes after restore.

  • [ ] Step 3: Commit
git add Dockerfile
git commit -m "perf: fix NuGet layer caching in Dockerfile development stage"

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 build stage 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
grep -n "FROM\|COPY\|RUN\|--no-cache" Dockerfile

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.hcl content
// 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-cache is removed and dotnet-build target exists
grep -n "no-cache\|dotnet-build\|contexts" docker-bake.hcl

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.yaml content
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
grep -n "nuget-packages" compose.dev.yaml

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
docker compose -f compose.yaml -f compose.dev.yaml build --dry-run 2>&1 | head -20

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
docker compose --env-file .env -f compose.yaml -f compose.dev.yaml build

Note the time taken for dotnet restore.

  • [ ] Step 3: Touch a .cs file 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
docker buildx bake commandcenter-bff-frontdoor commandcenter-webapi

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
docker compose --env-file .env -f compose.yaml -f compose.dev.yaml watch

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.