Container pipelines
A container pipeline has one job beyond building and testing: to make the exact image that was tested the exact image that runs in production. Everything else — tags, scanning, promotion — exists to keep that true.
Pipeline shape
commit
│
├─ lint + unit tests
├─ build image (multi-stage)
├─ scan image (OS + language packages)
├─ SBOM generation
├─ push to ECR ── tag: <sha>-<version>-<env>
│
└─ deploy (staging) → verify → deploy (production)
Dockerfile standard
# ── build stage ──────────────────────────────────────────────
FROM node:22-bookworm-slim@sha256:<digest> AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev=false
COPY . .
RUN npm run build && npm prune --omit=dev
# ── runtime stage ────────────────────────────────────────────
FROM node:22-bookworm-slim@sha256:<digest>
ENV NODE_ENV=production
WORKDIR /app
RUN useradd --system --uid 10001 appuser
COPY /app/node_modules ./node_modules
COPY /app/dist ./dist
USER appuser
EXPOSE 8080
HEALTHCHECK \
CMD node -e "fetch('http://localhost:8080/health').then(r=>process.exit(r.ok?0:1))"
CMD ["node", "dist/server.js"]
| Rule | Reason |
|---|---|
| Multi-stage build | Runtime image contains only what it needs; smaller attack surface |
| Base image pinned by digest | A floating tag silently changes the build |
| Non-root user | Limits the impact of a process compromise |
| No build tools in the runtime stage | Reduces both size and vulnerability count |
Explicit CMD (exec form) | Signal handling: the process receives SIGTERM and drains |
| Health check | The orchestrator can distinguish running from healthy |
.dockerignore | Prevents secrets, tests and local state entering the image |
Image tagging
<repository>:<git-sha-short>-<semver>-<environment>
Example:
example-service:a1b2c3d-1.4.0-prod
- The commit hash makes the image traceable to source.
- The semantic version communicates intent to a human.
- The environment prevents a staging image being promoted accidentally as production.
Rules.
-
ECR repositories are set to immutable tags:
aws ecr put-image-tag-mutability \--repository-name example-service \--image-tag-mutability IMMUTABLE -
latestis never deployed. It is ambiguous by construction and makes rollback a guess. -
A tag is never reused, even after the image is deleted.
Scanning and SBOM
Every image is scanned in the pipeline before it is pushed:
# Example: build, scan and push
docker build -t "$IMAGE" .
trivy image --exit-code 1 --severity CRITICAL,HIGH "$IMAGE"
syft "$IMAGE" -o cyclonedx-json > sbom.json
docker push "$IMAGE"
| Artefact | Purpose |
|---|---|
| Scan report | Blocks the build on critical or high findings with a fix available |
| SBOM (CycloneDX) | Answers "are we affected?" when the next CVE lands |
| Provenance attestation | Records what built the image and from which commit |
Continuous scanning continues in ECR after the push, because a clean image today can become vulnerable tomorrow.
Registry lifecycle
| Policy | Rule |
|---|---|
| Untagged images | Expire after 7 days |
| Production images | Retain indefinitely |
| Non-production images | Retain 90 days |
| Pull-through cache | Enabled for approved upstream base images |
{
"rules": [
{
"rulePriority": 1,
"description": "Expire untagged images after 7 days",
"selection": {
"tagStatus": "untagged",
"countType": "sinceImagePushed",
"countUnit": "days",
"countNumber": 7
},
"action": {"type": "expire"}
}
]
}
Deployment
ECS
- The task definition references the image by digest.
- A new revision is registered, and the service updates with a circuit breaker and rollback enabled.
- Health checks gate task replacement.
resource "aws_ecs_service" "app" {
name = "example-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = 3
launch_type = "FARGATE"
deployment_circuit_breaker {
enable = true
rollback = true
}
deployment_minimum_healthy_percent = 100
deployment_maximum_percent = 200
}
EKS
- The deployment references the image by digest.
- Rolling update with
maxUnavailable: 0andmaxSurge: 1. - Readiness probes gate traffic;
terminationGracePeriodSecondsallows draining. kubectl rollout undois the rollback path, and it is exercised in drills.
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
spec:
terminationGracePeriodSeconds: 30
containers:
- name: app
image: <account>.dkr.ecr.us-east-1.amazonaws.com/example-service@sha256:<digest>
readinessProbe:
httpGet: {path: /health, port: 8080}
periodSeconds: 5
lifecycle:
preStop:
exec: {command: ["sleep", "10"]}
Verification after deploy
- Health endpoint returns 200 within the expected startup window.
- Error rate and latency are compared against the pre-deployment baseline.
- A synthetic transaction exercises the critical path.
- The deployment is recorded with the image digest, commit, and approver.