Guide · AWS DevOps

MCP Server CodeBuild — Docker image builds, buildspec.yml phases, S3 layer caching, ECR push

AWS CodeBuild is the managed build service used to compile, test, and package MCP server Docker images before pushing them to ECR and deploying to ECS. Three CodeBuild pitfalls account for the majority of MCP server CI failures: privileged mode is required for Docker (Docker daemon is not available inside CodeBuild build environments by default — you must enable privilegedMode: true on the project or the build environment; without it, docker build fails with "Cannot connect to the Docker daemon"), buildspec.yml phase ordering (build failure skips post_build conditionally — only when the phase ordering causes it; in many configurations post_build runs even when build fails, meaning a failed docker build may still attempt the ECR push; check $CODEBUILD_BUILD_SUCCEEDING in post_build to gate the push), and S3 layer caching is not free or instantaneous (CodeBuild S3 cache compresses the cache directories and uploads to S3 at the end of each build, then downloads and decompresses at the start of the next — on large node_modules trees, cache upload/download can take 45-90 seconds, which sometimes exceeds the savings; benchmark before enabling).

TL;DR

Enable privilegedMode: true for Docker builds. Use CODEBUILD_RESOLVED_SOURCE_VERSION (the Git SHA) as the ECR image tag — never push :latest as the only tag. Check $CODEBUILD_BUILD_SUCCEEDING in post_build before pushing to ECR. Use --cache-from with a previously-pulled :cache tag for Dockerfile layer caching when S3 cache overhead exceeds the savings.

buildspec.yml: complete MCP server Docker build pipeline

# buildspec.yml — place at repository root
version: 0.2

env:
  variables:
    AWS_DEFAULT_REGION: us-east-1
    ECR_REPO_NAME: mcp-server
  # Pull AWS account ID from SSM (avoids hardcoding in source)
  parameter-store:
    AWS_ACCOUNT_ID: /codebuild/aws-account-id

phases:
  pre_build:
    commands:
      # Authenticate to ECR (token expires after 12 hours)
      - echo "Authenticating to ECR..."
      - aws ecr get-login-password --region $AWS_DEFAULT_REGION |
          docker login --username AWS --password-stdin
          $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com

      # Set image tags using the Git SHA (deterministic, traceable)
      - IMAGE_TAG=$CODEBUILD_RESOLVED_SOURCE_VERSION
      - SHORT_SHA=${IMAGE_TAG:0:8}
      - ECR_URI=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$ECR_REPO_NAME

      # Pull cache layer from ECR (ignore failure if it doesn't exist yet)
      - docker pull $ECR_URI:cache || true

  build:
    commands:
      - echo "Building Docker image..."
      - |
        docker build \
          --cache-from $ECR_URI:cache \
          --build-arg BUILDKIT_INLINE_CACHE=1 \
          --tag $ECR_URI:$SHORT_SHA \
          --tag $ECR_URI:cache \
          .

      # Run tests inside the container
      - docker run --rm $ECR_URI:$SHORT_SHA npm test

  post_build:
    commands:
      # Gate the push on build success — post_build runs even when build fails
      - |
        if [ "$CODEBUILD_BUILD_SUCCEEDING" != "1" ]; then
          echo "Build failed — skipping ECR push"
          exit 0
        fi

      - echo "Pushing image to ECR..."
      - docker push $ECR_URI:$SHORT_SHA
      - docker push $ECR_URI:cache

      # Write image metadata for downstream pipeline stages
      - printf '{"name":"mcp-server","imageUri":"%s"}' "$ECR_URI:$SHORT_SHA" > imagedefinitions.json

      - echo "Build complete: $ECR_URI:$SHORT_SHA"

# Pass image metadata to downstream pipeline stage (ECS deploy)
artifacts:
  files:
    - imagedefinitions.json

CodeBuild project configuration

// CDK: CodeBuild project for MCP server Docker builds
import * as codebuild from "aws-cdk-lib/aws-codebuild";
import * as codecommit from "aws-cdk-lib/aws-codecommit";  // or use GitHub source

const buildProject = new codebuild.Project(this, "McpBuild", {
  projectName: "mcp-server-build",
  source: codebuild.Source.gitHub({
    owner: "my-org",
    repo: "mcp-server",
    webhook: true,
    webhookFilters: [
      codebuild.FilterGroup.inEventOf(codebuild.EventAction.PUSH)
        .andBranchIs("main"),
    ],
  }),
  environment: {
    buildImage: codebuild.LinuxBuildImage.STANDARD_7_0,  // Amazon Linux 2023
    computeType: codebuild.ComputeType.MEDIUM,           // 7 GB RAM — needed for multi-platform Docker builds
    privileged: true,    // REQUIRED for Docker daemon access
  },
  environmentVariables: {
    AWS_DEFAULT_REGION: { value: this.region },
  },
  cache: codebuild.Cache.local(
    codebuild.LocalCacheMode.DOCKER_LAYER,    // Docker layer cache (ephemeral, per-build-host)
    codebuild.LocalCacheMode.SOURCE,          // Source cache
  ),
  buildSpec: codebuild.BuildSpec.fromSourceFilename("buildspec.yml"),
  timeout: cdk.Duration.minutes(30),          // Explicit timeout — default 60 min
  logging: {
    cloudWatch: {
      logGroup: new logs.LogGroup(this, "BuildLogs", {
        logGroupName: "/codebuild/mcp-server",
        retention: logs.RetentionDays.ONE_WEEK,
        removalPolicy: cdk.RemovalPolicy.DESTROY,
      }),
    },
  },
});

// Grant the CodeBuild service role permission to push to ECR
repository.grantPullPush(buildProject.role!);

Compute type selection: SMALL (3 GB, 2 vCPU) is sufficient for single-platform linux/amd64 builds. Use MEDIUM (7 GB) or LARGE (15 GB) for multi-platform builds (linux/amd64,linux/arm64) which use QEMU emulation and are significantly more memory-intensive. Docker BuildKit multi-platform builds on SMALL frequently OOM-kill mid-layer.

Optimizing build time: layer caching strategies

CodeBuild offers three caching strategies with different trade-offs for MCP server Docker builds.

StrategyHow it worksBest forCaveat
--cache-from ECRPull previous image as layer cache via Docker --cache-from; push updated cache image after buildDockerfile layers that rarely change (base OS, npm install step) when the cache hit is highPulling the cache image costs ECR data transfer time; cache is only useful if base layers are stable
Local Docker layer cacheCodeBuild reuses Docker layer cache within the same build host (ephemeral; not shared between builds)Short-running builds where the same host is likely to be reusedNo guarantee of same host — effective cache hit rate is ~40-60%; not reliable for CI
S3 cacheSpecified directories (/root/.npm, node_modules) are tarred and uploaded to S3 at build end; downloaded at build startnpm/pip install steps when package.json rarely changesUpload/download of large node_modules (500MB+) can take 60-90s — benchmark vs. fresh install
# Optimized Dockerfile structure for maximum layer cache reuse
# 1. Copy only package files first (changes rarely)
# 2. Install dependencies (cached until package files change)
# 3. Copy source code (changes every commit)
# 4. Build application

FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile

FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]

Common failure modes

SymptomCauseFix
"Cannot connect to the Docker daemon at unix:///var/run/docker.sock"Privileged mode not enabled on the build environmentSet privileged: true in the build environment configuration
ECR push succeeds even when tests failpost_build runs after build failure in some phase configurations; test exit code not surfacedCheck $CODEBUILD_BUILD_SUCCEEDING at the start of post_build and exit 0 (skips push, doesn't fail build) or exit 1 (fails build)
OOM during multi-platform Docker buildQEMU emulation for linux/arm64 target is memory-intensive; SMALL compute (3 GB) is insufficientUpgrade to MEDIUM (7 GB) or LARGE (15 GB) compute type
"denied: User is not authorized to perform ecr:InitiateLayerUpload"CodeBuild service role missing ECR push permissionsGrant repository.grantPullPush(project.role) in CDK, or add push permissions to the role
S3 cache download takes longer than fresh npm installLarge node_modules tree — S3 cache compression + transfer overhead exceeds cold install timeBenchmark both paths; use --cache-from ECR strategy instead, or set S3 cache only on /root/.npm (not node_modules itself)
Build always uses :latest tag, making rollback difficultbuildspec uses a fixed tag instead of git SHAUse CODEBUILD_RESOLVED_SOURCE_VERSION (full SHA) or its 8-char prefix as the image tag
CodeBuild reports timeout after 60 minutesDefault build timeout is 60 minutes; multi-platform builds or large test suites can exceed thisSet explicit timeout in project config (up to 480 minutes / 8 hours)