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.
| Strategy | How it works | Best for | Caveat |
|---|---|---|---|
| --cache-from ECR | Pull previous image as layer cache via Docker --cache-from; push updated cache image after build | Dockerfile layers that rarely change (base OS, npm install step) when the cache hit is high | Pulling the cache image costs ECR data transfer time; cache is only useful if base layers are stable |
| Local Docker layer cache | CodeBuild 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 reused | No guarantee of same host — effective cache hit rate is ~40-60%; not reliable for CI |
| S3 cache | Specified directories (/root/.npm, node_modules) are tarred and uploaded to S3 at build end; downloaded at build start | npm/pip install steps when package.json rarely changes | Upload/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
| Symptom | Cause | Fix |
|---|---|---|
| "Cannot connect to the Docker daemon at unix:///var/run/docker.sock" | Privileged mode not enabled on the build environment | Set privileged: true in the build environment configuration |
| ECR push succeeds even when tests fail | post_build runs after build failure in some phase configurations; test exit code not surfaced | Check $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 build | QEMU emulation for linux/arm64 target is memory-intensive; SMALL compute (3 GB) is insufficient | Upgrade 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 permissions | Grant repository.grantPullPush(project.role) in CDK, or add push permissions to the role |
| S3 cache download takes longer than fresh npm install | Large node_modules tree — S3 cache compression + transfer overhead exceeds cold install time | Benchmark 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 difficult | buildspec uses a fixed tag instead of git SHA | Use CODEBUILD_RESOLVED_SOURCE_VERSION (full SHA) or its 8-char prefix as the image tag |
| CodeBuild reports timeout after 60 minutes | Default build timeout is 60 minutes; multi-platform builds or large test suites can exceed this | Set explicit timeout in project config (up to 480 minutes / 8 hours) |