CoursesAWS DevOps Engineer ProfessionalCodeBuild: fast, secure builds

CodeBuild: fast, secure builds

buildspec, caching, tests, scoped IAM.

Intermediate30 min · lesson 2 of 15

There is a machine shop across town that you do not own. You drop off raw materials and a work order. The shop wheels out a bench, fits it with exactly the tools your job needs, makes the part, hands it back, then strips the bench down to nothing. You pay for the minutes that bench existed and not a second more. That shop is AWS CodeBuild, the build service inside Amazon Web Services (AWS). It is fully managed, meaning AWS runs the machines and you never log into one. Every build gets a fresh, disposable container (a small isolated box holding one job's tools and files), runs the steps you wrote down, ships the output, and deletes the box. No build servers to patch. No idle capacity quietly billing you. And no slow drift where last week's build host has different software on it than this week's.

The project, the buildspec, and the four phases

Two pieces do the work, and they live in different places on purpose. The build project is the standing paperwork you file once with AWS: where the source comes from, which container image to start from (a prebuilt filesystem with a compiler or runtime already inside), how much compute to rent, which IAM service role the build assumes, and where the finished files go. IAM is Identity and Access Management, the AWS permission system, and a service role is the identity CodeBuild wears while it works. The buildspec (buildspec.yml, spec version 0.2) is the work order, and it sits in your repository next to the code. Because it lives there, it is version-controlled, someone reads it in a pull request, and any old commit can be rebuilt the way it was built then. Its steps are grouped into four phases that run in order: install (language runtimes and tools), pre_build (registry logins and setup), build (compile and test), and post_build (package and publish).

Failure handling is the part worth burning into memory. Each phase is a list of shell commands. Any command that exits non-zero (a non-zero exit code is how a Unix program says it failed) stops there, skips the rest of that phase, and abandons the build steps still queued behind it. Two things happen anyway after a failed build. First, post_build still runs, the way a finally block runs no matter how the code above it ended, so you can emit logs, fire a notification, or dump diagnostics on the way out the door. Second, and this is the one that catches people, the UPLOAD_ARTIFACTS phase is always attempted, even when the build phase fails. A failed run can still push whatever half-finished files happen to be on disk. Never treat failure as the thing that holds back a broken artifact. Gate the deploy on the build's status in the pipeline instead. The env block resolves variables at run time, including secrets pulled from Secrets Manager or Parameter Store, and reports tells CodeBuild which test and coverage files to read back in.

buildspec.yml
version: 0.2
env:
variables:
IMAGE_REPO: 111122223333.dkr.ecr.us-east-1.amazonaws.com/checkout
parameter-store:
NODE_ENV: /checkout/prod/node-env # pulled from SSM at runtime
secrets-manager:
NPM_TOKEN: prod/npm:token # pulled from Secrets Manager
phases:
install:
runtime-versions:
nodejs: 20
commands:
- npm ci --prefer-offline # restored from cache when warm
pre_build:
commands:
- export TAG="${CODEBUILD_RESOLVED_SOURCE_VERSION:0:8}" # immutable, = commit SHA
- aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin "${IMAGE_REPO%%/*}" # registry host, not the repo path
build:
commands:
- npm run build
- npm test -- --reporters=jest-junit # emits junit.xml
post_build: # runs even if build fails
commands:
- docker build -t "$IMAGE_REPO:$TAG" .
- docker push "$IMAGE_REPO:$TAG"
reports:
jest:
files: ["junit.xml"]
file-format: JUNITXML
artifacts:
files: ["dist/**/*"]
cache:
paths:
- "node_modules/**/*"

CodeBuild hands every run some context through environment variables. CODEBUILD_RESOLVED_SOURCE_VERSION holds the exact commit that was checked out, and CODEBUILD_BUILD_ID, CODEBUILD_BUILD_ARN (an ARN is an Amazon Resource Name, the unique address of any AWS object) and CODEBUILD_WEBHOOK_HEAD_REF fill in the rest. Use that commit SHA, the fingerprint Git puts on every commit, to tag images so one tag means one build forever, rather than reusing latest and losing track of what actually shipped. The buildspec can live at the repo root, at a path you pick, or be typed straight into the project. Keeping it in the repo is what puts your build steps under code review. You can also override the buildspec, the environment variables and the source version for a single run with start-build, which is how one project serves many branches without being cloned five times.

Machine size, cold starts, and caches

Compute comes as a ladder of sizes: BUILD_GENERAL1_SMALL, MEDIUM, LARGE and 2XLARGE, with ARM and GPU variants alongside. Two newer choices change how long you sit waiting before anything happens. Lambda compute (BUILD_LAMBDA_* on LINUX_LAMBDA_CONTAINER) starts almost the instant you trigger it, with no PROVISIONING wait at all, but it cannot run Docker or privileged mode. Reserved-capacity fleets (create-fleet) keep machines warm and idling, like a taxi rank with the engines already running, so builds skip the PROVISIONING cold start completely. That cold start is usually 10 to 20 seconds. You pay for the idle capacity and you get start times you can predict.

Caching decides whether the second build is fast or as slow as the first. Local caching (LOCAL_DOCKER_LAYER_CACHE, LOCAL_SOURCE_CACHE, LOCAL_CUSTOM_CACHE) reuses the physical host a previous build ran on. Land on a warm host and it flies. It is strictly best-effort, though: land on a fresh host and you get a silent miss with nothing in the log saying so. S3 caching keeps the cache in a bucket instead (S3 is Simple Storage Service, the AWS object store), so it survives across hosts and across your whole fleet, in exchange for a download at the start and an upload at the end of every run. Size the compute to the work, because a bigger instance finishes sooner but costs more per minute. And split jobs that do not depend on each other across a batch build so they run side by side instead of queueing up behind one another.

start-build.sh
$ aws codebuild start-build --project-name checkout-service --region us-east-1 \
--query 'build.{id:id,status:buildStatus}'
{
"id": "checkout-service:9f3c1e2a-4b7d-4e21-a0c8-1d2e3f4a5b6c",
"status": "IN_PROGRESS"
}
# Once it finishes, read the phase-by-phase breakdown (CodeBuild's internal phases)
$ aws codebuild batch-get-builds --ids checkout-service:9f3c1e2a-4b7d-4e21-a0c8-1d2e3f4a5b6c \
--query 'builds[0].phases[].{phase:phaseType,status:phaseStatus,secs:durationInSeconds}' \
--output table
---------------------------------------------
| BatchGetBuilds |
+-------------------+------------+----------+
| phase | status | secs |
+-------------------+------------+----------+
| SUBMITTED | SUCCEEDED | 0 |
| QUEUED | SUCCEEDED | 1 |
| PROVISIONING | SUCCEEDED | 14 |
| DOWNLOAD_SOURCE | SUCCEEDED | 3 |
| INSTALL | SUCCEEDED | 6 | <- npm ci from cache
| PRE_BUILD | SUCCEEDED | 2 |
| BUILD | SUCCEEDED | 41 |
| POST_BUILD | SUCCEEDED | 19 |
| UPLOAD_ARTIFACTS | SUCCEEDED | 2 |
| FINALIZING | SUCCEEDED | 1 |
| COMPLETED | SUCCEEDED | 0 |
+-------------------+------------+----------+

The one credential in play: the build's service role

The build assumes an IAM service role, so no credentials are baked into the container at all. It works like a visitor badge issued at the front desk, valid for this visit and no other. That badge is exactly where least privilege earns its keep. Scope it to the one ECR repository it pushes to (ECR is Elastic Container Registry, the AWS store for container images), the exact Parameter Store paths it reads, and the individual Secrets Manager secrets it needs. Never *. Pull those secrets in through the env block's secrets-manager and parameter-store keys so the values arrive at run time and never land in the repo. Pushing an image to ECR needs two things: privilegedMode=true, which allows the Docker daemon to run inside the build container, and a short-lived authorization token, which aws ecr get-login-password mints for docker login. Treat privilegedMode as a deliberate step up in trust, not a box you tick on every project. Turn it on only where you genuinely build images.

create-project.sh
# Register a project with an S3 dependency cache and a tightly scoped service role
$ aws codebuild create-project \
--name checkout-service \
--source type=CODEPIPELINE \
--artifacts type=CODEPIPELINE \
--environment 'type=LINUX_CONTAINER,image=aws/codebuild/amazonlinux-x86_64-standard:5.0,computeType=BUILD_GENERAL1_MEDIUM,privilegedMode=true' \
--service-role arn:aws:iam::111122223333:role/codebuild-checkout \
--cache 'type=S3,location=my-build-cache/checkout' \
--query 'project.{name:name,compute:environment.computeType,privileged:environment.privilegedMode,cache:cache.type}'
{
"name": "checkout-service",
"compute": "BUILD_GENERAL1_MEDIUM",
"privileged": true,
"cache": "S3"
}

Test reports and a build you can trust twice

CodeBuild reads your test results and files them into report groups. Point reports at a JUnit, TestNG, Cucumber or coverage file and the console draws pass and fail trends run over run, so a flaky test stops being team folklore and becomes a line on a chart. Gate the pipeline on that outcome and one failing test blocks the deploy. For an artifact anyone can trust later, pin the base image by digest (@sha256:...) instead of a moving tag like latest, pin tool versions in install, and run a vulnerability scan (Trivy, docker scout, or ECR scan-on-push) that fails the build on serious findings. Same source and same pinned inputs should produce the same artifact every time, which is the bet every delivery pipeline is quietly making.

reports.sh
# Inspect the ingested test report for a build
$ aws codebuild batch-get-reports \
--report-arns arn:aws:codebuild:us-east-1:111122223333:report/checkout-service-jest:8ac4f1 \
--query 'reports[0].{status:status,total:testSummary.total,failed:testSummary.statusCounts.FAILED}'
{
"status": "FAILED",
"total": 214,
"failed": 1
}
# One failing test flips the whole report to FAILED — wire this status into the pipeline gate
# so the artifact never reaches CodeDeploy.

What it costs, and the limits you will hit

You are billed per build minute, priced by compute type, roughly $0.005 a minute for general1.small on Linux on-demand and climbing with size. The free tier covers 100 general1.small minutes a month. The default build timeout is 60 minutes and the ceiling is 480 (eight hours). The number of concurrently running builds is a soft Service Quota, so when your pipelines start queueing you file a request to raise it. Two levers move the bill more than anything else. Caching stops you paying to reinstall the same dependencies over and over. Compute size matters the other way round to how people expect: a MEDIUM that turns a 40-minute build into 20 often costs less in total than a SMALL that crawls. Reserved fleets buy predictable start times with idle spend. On-demand buys zero idle spend with cold starts. Choose per project, not once for the whole account.

Anything your build prints ends up in the log
Environment variables of type PLAINTEXT are visible in the CodeBuild console and stream straight to CloudWatch Logs, so credentials never belong there. Values from Secrets Manager and Parameter Store leak the same way the moment a command echoes them, or the shell runs with tracing turned on (set -x prints every command before it executes). Inject secrets only through the secrets-manager and parameter-store env keys, keep tracing off in any phase that touches them, and assume that everyone with read access to that log group can read whatever the build printed.
Anatomy of a CodeBuild run
Inputs (from your repo and AWS)
Source commit
pulled into a fresh container
buildspec.yml
phases, reports, cache; versioned in the repo
Cache (S3 or local)
restored before install; local is best-effort
Ephemeral build container
Managed or custom image
pinned by digest for reproducibility
Compute: SMALL → 2XLARGE / Lambda
disposed the instant the build ends
Phases install → pre_build → build → post_build
post_build runs even on failure
AWS access via IAM service role
Secrets Manager / SSM
secrets injected at runtime, not committed
ECR
get-login-password, then push image
S3 / CloudWatch Logs
artifacts + build logs land here
One scoped role is the only credential in play; the container and all its state vanish when the build completes.

The four phases are a contract: install, then pre_build, then build, then post_build. Be careful about what you allow into the cache. A poisoned npm or Maven directory, or a poisoned Docker layer, is a supply-chain compromise that ships with a green checkmark beside it.

The CodeBuild service role should be able to push to the one ECR repository it owns and read the handful of secrets it needs from Secrets Manager or SSM (Systems Manager, which holds Parameter Store). Nothing beyond that. Privileged mode belongs to Docker builds, not to the project template everybody copies.

Batch builds and matrix builds are waiting when you need scale. Start with one clean project that emits a test report you can actually read in the console, before you write YAML so clever that nobody else on the team can follow it.

Try this

Start a build on a lab project and read its phases back. Use a project that already exists, so you are not inventing IAM policy halfway through the exercise.

terminal
aws codebuild start-build --project-name app-build --query 'build.{Id:id,Status:buildStatus}' --output table
aws codebuild batch-get-builds --ids app-build:abc123 \
--query 'builds[0].{Status:buildStatus,Phases:phases[].{N:name,S:phaseStatus}}' --output json
output
-----------------------------
| StartBuild |
+---------------+-----------+
| Id | Status |
+---------------+-----------+
| app-build:abc | IN_PROGRESS|
+---------------+-----------+
{"Status":"SUCCEEDED","Phases":[{"N":"BUILD","S":"SUCCEEDED"},{"N":"POST_BUILD","S":"SUCCEEDED"}]}

Takeaway

Remember: a throwaway container with a narrow role beats a long-lived Jenkins box you keep alive and keep patching. Cache to buy speed, pin digests to buy trust.

Next: add a test report export to your buildspec, and make sure a failing test actually stops the image push instead of letting post_build push it anyway.

Quick check
01A command in your build phase exits non-zero. What does CodeBuild do next?
Correct — post_build behaves like a finally block, and AWS documents UPLOAD_ARTIFACTS as always attempted even when the build phase fails, so a failed run can still push whatever files exist on disk. Gate the deploy on build status rather than assuming failure suppresses the upload.
Incorrect — Only the remaining build commands are abandoned. post_build still runs, and UPLOAD_ARTIFACTS is still attempted after it.
Incorrect — There is no implicit retry. CodeBuild retries only when you set on-failure: RETRY on the phase; by default the phase fails on the first non-zero exit.
Incorrect — CodeBuild has no rollback or cleanup step. It never undoes an ECR push and never deletes artifacts. That cleanup is yours, or the pipeline's.
02Your team wants builds to start almost immediately, with no PROVISIONING cold-start wait, so a colleague suggests moving the project to CodeBuild Lambda compute. Which limitation do you have to plan around?
Correct — Lambda compute starts near-instantly, but the Docker daemon and privileged mode are off the table, so image builds have to live somewhere else.
Incorrect — No. The buildspec phase lifecycle is unchanged on Lambda compute; every phase still runs.
Incorrect — No. Lambda compute still uses an IAM service role, and secrets still come from Secrets Manager or Parameter Store.
Incorrect — No. That is not a documented limit of Lambda compute. The real constraint is the missing Docker daemon and privileged mode.
03A CodeBuild project reinstalls the same large set of dependencies on nearly every run, and builds land on different hosts across a reserved-capacity fleet. You want repeat builds to reuse those cached dependencies reliably, whichever host they get. Which caching choice is the BEST fit?
Incorrect — No. Local caching is best-effort and tied to one host, and the source cache is about Git history rather than installed dependencies.
Correct — S3 caching keeps the cache centrally, so any host in the fleet can restore it, at the price of a download and an upload each run.
Incorrect — No. Docker layer caching is also best-effort and host-bound, so a fresh host is a silent miss.
Incorrect — No. A bigger instance still pays for the reinstall and costs more per minute, without removing the repeated work.

CodeBuild's job ends at a tested, scanned, commit-stamped artifact sitting in ECR or S3. Getting that artifact onto running compute, and pulling it back automatically when a release starts misbehaving, is the next lesson, CodeDeploy & rollback.

Related