Skip to main content

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 --from=build --chown=appuser:appuser /app/node_modules ./node_modules
COPY --from=build --chown=appuser:appuser /app/dist ./dist
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD node -e "fetch('http://localhost:8080/health').then(r=>process.exit(r.ok?0:1))"
CMD ["node", "dist/server.js"]
RuleReason
Multi-stage buildRuntime image contains only what it needs; smaller attack surface
Base image pinned by digestA floating tag silently changes the build
Non-root userLimits the impact of a process compromise
No build tools in the runtime stageReduces both size and vulnerability count
Explicit CMD (exec form)Signal handling: the process receives SIGTERM and drains
Health checkThe orchestrator can distinguish running from healthy
.dockerignorePrevents 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
  • latest is 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"
ArtefactPurpose
Scan reportBlocks the build on critical or high findings with a fix available
SBOM (CycloneDX)Answers "are we affected?" when the next CVE lands
Provenance attestationRecords 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​

PolicyRule
Untagged imagesExpire after 7 days
Production imagesRetain indefinitely
Non-production imagesRetain 90 days
Pull-through cacheEnabled 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: 0 and maxSurge: 1.
  • Readiness probes gate traffic; terminationGracePeriodSeconds allows draining.
  • kubectl rollout undo is 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.