Your CI/CD pipeline doesn't produce binaries, JAR files, or executables anymore. It produces container images. An image is your deployable artifact — the versioned, tested, signed, immutable thing that goes into production. This guide explains the container-native CI/CD pipeline: how images become your single source of truth, where to build them, how to scan them, and how to promote them from dev to staging to production.
graph LR Git["Git Commit"] --> Build["Build Image<br/>docker build"] Build --> Tag["Tag Image<br/>registry/app:v1.0"] Tag --> Scan["Security Scan<br/>Trivy/Grype"] Scan --> Registry["Push to Registry<br/>Docker Hub/ECR"] Registry --> Sign["Sign Image<br/>Cosign"] Sign --> Promote["Promote Through<br/>Dev → Staging → Prod"] Promote --> Deploy["Deploy with<br/>Verification"] style Build fill:#fff9c4 style Scan fill:#ffe0b2 style Registry fill:#c8e6c9 style Deploy fill:#c8e6c9Table of Contents
- The Container-Native Pipeline Architecture
- Building Images in CI
- Image Tagging Strategies
- Registry as the Artifact Store
- Scanning in the Pipeline
- Signing and Attestation
- Multi-Stage Builds for CI
- Caching Strategies for Speed
- Image Promotion Model
- GitOps and Images
- Next Steps
The Container-Native Pipeline Architecture
Traditional Pipeline (Pre-Container)
In a traditional pipeline, the workflow flows through several distinct stages with separate artifacts at each step. Starting with source code, the first step is compilation into a binary, JAR file, or executable. Once compiled, the binary goes through unit testing to produce a tested binary. The tested binary is then packaged into a distribution package like an RPM, DEB, or WAR file. Finally, the distribution package is deployed and becomes a running process. Throughout this pipeline, artifacts remain separate: binaries, distribution packages, and configuration files are managed independently from the code, which creates significant problems.
Container-Native Pipeline
In a container-native pipeline, the workflow is unified around a single artifact: the container image. The process begins with source code and a Dockerfile, which are used to build an image via docker build, producing a container image identified by a sha256 hash. This image then goes through testing via docker run to verify it works correctly. The tested image is scanned using tools like Trivy or Snyk to check for vulnerabilities, producing a scanned image with a clean bill of health. The scanned image is signed using Cosign to create a cryptographically signed image. The signed image is pushed to a registry, where it is stored and made available for deployment. Finally, the image is deployed using kubectl apply, launching pods that run the image.
The key difference is that there is a single artifact: the container image. Everything is versioned through the image tag and digest. The image is immutable, meaning it cannot change once pushed. The image is signed, providing cryptographic verification.
Why This Matters
In a traditional pipeline, multiple artifacts must be coordinated: you build a binary, test it, package it with configuration files, and at deployment time you combine the binary with config files, environment variables, and secrets. This introduces several problems: configuration lives outside the code and is not tested with it; environment differences emerge where what you tested in development may not match what you deploy to production; and reproducibility becomes difficult—can you rebuild the exact same binary today?
In a container-native pipeline, you build an image that contains the binary plus all dependencies plus configuration. You test the entire image as a unit. You scan the image for vulnerabilities. You deploy the image unchanged. What you tested in staging is exactly what runs in production, ensuring parity between test and production.
The result follows a powerful operational principle: build once, test once, deploy many times. You verify the exact artifact you will deploy, eliminating configuration drift and ensuring reproducibility.
Building Images in CI
Traditional Approach: Docker-in-Docker (DinD)
The traditional approach to building container images in CI is Docker-in-Docker (DinD). Your CI job has Docker installed and can build images using Docker commands. The typical workflow involves the CI system spinning up a container with a Docker client, the Docker client communicates with a Docker daemon running in a sibling container (the dind service), the daemon builds the image, and the image is pushed to a registry.
The advantage of this approach is simplicity: you use standard Docker commands that any engineer already knows. The disadvantages are significant. Docker-in-Docker uses nested virtualization—a container running Docker running containers—which adds overhead. There are security implications because the approach requires privileged mode to function. It is slow because the full container isolation mechanism runs for each build. For these reasons, DinD is becoming less common.
Modern Approach: Kaniko
Kaniko is a modern alternative that builds container images without requiring a Docker daemon at all. Kaniko reads the Dockerfile, processes each instruction sequentially, builds image layers using the OCI Image Specification, and pushes the result to the registry, all without needing a Docker daemon running.
The advantages are substantial. Because there is no Docker daemon, the approach is more secure and doesn't require privileged mode. Builds are faster because Kaniko can parallelize certain operations. It works in restricted environments where Docker daemons are not available. The disadvantages are that Kaniko is less feature-complete than Docker and has a smaller ecosystem of extensions.
Best: BuildKit (Docker's Modern Builder)
BuildKit is Docker's next-generation builder and represents the official path forward. BuildKit parses the Dockerfile and creates a directed acyclic graph of build steps. It executes stages in parallel where possible, using intelligent caching to reuse previous build results. It can cache build artifacts in the registry, making subsequent builds much faster. BuildKit is fast because of parallel execution and smart caching, secure because it does not require a Docker daemon, and provides advanced features like cache mounts for dependency caches and secrets that can be passed to build steps without being baked into the image.
The only disadvantage is that BuildKit is slightly newer than Docker itself, though it is now Docker's recommended approach for all builds. Most CI systems now have native BuildKit support.
Image Tagging Strategies
Tag Requirements
Every image pushed to a registry needs a tag and digest. The tag is a human-readable identifier like myapp:v1.0.0. The digest is an immutable identifier based on the image's content, like myapp@sha256:abc123.... The full image reference combines the registry, repository name, and tag: registry.example.com/myapp:v1.0.0.
Strategy 1: Semantic Versioning
In semantic versioning, tags reflect the application version. Each release gets a version number: version 1.0.0, version 1.0.1, version 1.1.0. The advantage is that this is easy to understand and matches the release versions developers and users expect. The disadvantage is that manual tagging is required and duplicates are easy to create. This strategy is recommended for applications with explicit versioning where you release v1.0.0, then v1.0.1, then v1.1.0.
Strategy 2: Git SHA
In this strategy, tags are based on the git commit hash. Each commit gets a unique tag: commit abc123def456 produces tag myapp:abc123def456. This creates a deterministic mapping from commit to image. The advantage is that each commit has exactly one image and the correlation between code and image is clear. The disadvantage is that tags are not human-readable and don't indicate stability. This strategy is recommended for continuous integration pipelines.
Strategy 3: Date-Based
Tags include the date or time the image was built. The advantage is that it is easy to correlate the tag with when the image was built. The disadvantage is that tags are not semantic and don't indicate what changed. This strategy is recommended for scheduled builds or daily deployments.
Strategy 4: Immutable Tags (SemVer + Builds)
This strategy combines semantic versioning with a build number. If the version in source code is 1.0.0 and this is the 5th build, the tag is myapp:1.0.0-build.5. The advantage is that the tag is both semantic and unique. The disadvantage is that it is more complex. This strategy is recommended for enterprise deployments with strict versioning requirements.
Strategy 5: Multi-Level Tagging (Recommended)
The most flexible approach uses multiple tags for the same image. Build the image once, producing a digest. Then tag that same image multiple ways: with a version tag like myapp:1.0.0, with a latest tag like myapp:latest, with a stability tag like myapp:stable, and with an environment tag like myapp:prod. All these tags point to the same underlying image. In Kubernetes, production deployments can use the specific version tag (myapp:1.0.0) while development uses the latest tag (myapp:latest). This strategy provides flexibility across environments. This approach is recommended for deployments that need to support multiple environments.
Registry as the Artifact Store
Your registry—whether Docker Hub, Artifactory, Harbor, ECR, or GCR—is the central repository for built artifacts. It serves as the single source of truth for container images in your organization.
Registry Functions
A registry serves multiple critical functions in the deployment pipeline. It stores container images, maintaining a library of all versions. It manages versions, tracking which versions are production-ready. It enforces access control, determining who can push and pull images. It runs security scanning on images after they are pushed, identifying vulnerabilities. It tracks vulnerabilities over time, alerting when new CVEs are discovered in existing images. The registry acts as a central hub where CI/CD pipelines push images after building them, where Kubernetes clusters pull images for deployment, and where developers pull images for local development.
Push from CI
The CI job builds the image and pushes it to the registry. After building locally, the job authenticates to the registry, then pushes the image. The registry stores the image config (metadata), all layers (the actual filesystem content), and the manifest (the list of layers and their relationships).
Pull for Deployment
When Kubernetes deploys an application, it pulls the image from the registry. The Kubernetes deployment manifest specifies the image reference, and during pod startup, the kubelet pulls the image from the registry if it is not already cached locally. imagePullSecrets allow Kubernetes to authenticate to private registries.
Registry as Single Source of Truth
Before containers, a typical pipeline had source code in Git, compiled binaries in an artifact store, and configuration in separate config management systems. These artifacts were loosely coupled and could drift. With containers, the image in the registry becomes the single source of truth. The image contains the code (from your application), dependencies (from your package manager), and configuration (embedded in the image or provided at runtime). The pipeline is clean and linear: Git → Build → Image → Deploy.
Scanning in the Pipeline
Before production, scan images for vulnerabilities.
Where to Scan
Build-time scanning (in CI):
build_and_scan: image: aquasec/trivy:latest script: - docker build -t myapp:$CI_COMMIT_SHA . - trivy image --severity HIGH,CRITICAL myapp:$CI_COMMIT_SHA - docker push registry.example.com/myapp:$CI_COMMIT_SHARegistry scanning (after push):
# Registry watches images and scans continuously# Results appear in registry UIAdmission scanning (at deployment):
# Kubernetes admission controller# Intercepts pod creation# Scans image before allowing itScanning Tools
Tool | Scope | Use Case |
|---|---|---|
Trivy | Images, IaC, git repos | Open source, free, comprehensive |
Snyk | Images, dependencies, IaC | Commercial, developer-focused |
Grype | Images | Open source, language-aware |
Anchore | Images, compliance | Commercial, enterprise |
Clair | Registry integration | Open source, registry-native |
Example: Trivy
# Scan local imagetrivy image myapp:1.0.0 # Output2024-03-22T10:30:45.123Z INFO Vulnerability DB [Trivy DB] Downloading DB2024-03-22T10:30:45.456Z INFO Parsed config file [/root/.trivy/config.yaml]2024-03-22T10:30:45.789Z INFO Image [myapp:1.0.0] [docker]2024-03-22T10:30:46.012Z INFO Analyzing ... Critical Vulnerabilities- CVE-2024-1234 (openssl)- CVE-2024-5678 (glibc) High Vulnerabilities- CVE-2024-9012 (curl) CRITICAL: 2, HIGH: 1Policy: Fail CI if CRITICAL or HIGH vulnerabilities exist.
trivy image --severity CRITICAL --exit-code 1 myapp:1.0.0# Exit code 1 if vulnerabilities found → CI failsSigning and Attestation
Sign images to prove they came from your CI system and haven't been tampered with.
Cosign (Sigstore)
Open-source tool for signing container images:
# Build and signdocker build -t myapp:1.0.0 .cosign sign --key cosign.key registry.example.com/myapp:1.0.0 # Verifycosign verify --key cosign.pub registry.example.com/myapp:1.0.0Signing in CI Pipeline
# Sign after buildsign_image: script: - docker build -t myapp:$CI_COMMIT_SHA . - docker push registry.example.com/myapp:$CI_COMMIT_SHA - cosign sign --key cosign.key registry.example.com/myapp:$CI_COMMIT_SHAAttestation (Provenance)
In addition to signing the image, record metadata about the build:
# SBOM (Software Bill of Materials)cosign attach sbom --sbom sbom.json registry.example.com/myapp:1.0.0 # Build attestationcosign attest --predicate build-attestation.json registry.example.com/myapp:1.0.0Build attestation example:
{ "builder": "GitHub Actions", "buildId": "abc123", "buildTime": "2024-03-22T10:30:00Z", "sourceRepo": "github.com/example/myapp", "sourceCommit": "abc123def456", "sourceAuthor": "alice@example.com"}Verification at Deployment
# Kubernetes admission controller verifies signature before allowing imageapiVersion: constraints.gatekeeper.sh/v1beta1kind: ConstraintTemplatemetadata: name: k8srequiredimagesignedspec: crd: spec: names: kind: K8sRequiredImageSigned validation: openAPIV3Schema: type: object targets: - target: admission.k8s.gatekeeper.sh rego: | violation[{"msg": msg}] { image := input.review.object.spec.containers[_].image not signed_image(image) msg := sprintf("Image %v is not signed", [image]) } signed_image(image) { # Verify signature via Cosign }Result: Kubernetes rejects unsigned images at admission time.
Multi-Stage Builds for CI
Multi-stage builds keep build artifacts out of production images, resulting in dramatically smaller images and faster deployments. The difference is significant.
In a single-stage build, the Dockerfile builds the application and runs it from the same image. The final image includes the Go compiler, which is 800+ MB, plus the base OS at 50 MB, plus the application at 10 MB, totaling 860 MB. This is wasteful because the compiler is not needed at runtime.
In a multi-stage build, the first stage builds the application using a heavy build image (golang:1.20), producing a compiled binary. The second stage uses a lightweight runtime image (debian:bookworm-slim), copying the compiled binary from the first stage. The final image has only the base OS (50 MB) plus the application (10 MB), totaling 60 MB. The compiler is never included in the final image.
The benefit is substantial: the final image is 14 times smaller (60 MB vs 860 MB). In CI, you only push the 60 MB image to the registry, saving bandwidth and storage. Deployments are faster because pulling a 60 MB image is much quicker than pulling 860 MB.
Multi-Stage with Caching
# Build stageFROM golang:1.20 AS builder # Layer 1: Install dependenciesCOPY go.mod go.sum ./RUN go mod download # Layer 2: BuildCOPY . .RUN go build -o app . # Runtime stageFROM debian:bookworm-slimCOPY --from=builder /src/app /appCMD ["/app"]Caching benefit: If only main.go changes, go mod download is cached. Only the build step is rerun.
Caching Strategies for Speed
Building images from scratch is slow. Docker layer caching helps, but there's more.
Layer Cache (Built-in)
FROM python:3.11-slim # Layer 1: Install dependencies (cached if requirements.txt unchanged)COPY requirements.txt .RUN pip install -r requirements.txt # Layer 2: Copy code (invalidates when code changes)COPY . /app # Layer 3: Run appCMD ["python3", "app.py"]First build: 2 minutes (download, install, copy)
Second build (code changes): 10 seconds (reuse pip layer, rebuild code layer)
Rule: Put stable instructions first (dependencies), changing instructions last (code).
BuildKit Remote Caching
BuildKit can use a registry as cache storage:
# GitHub Actions- uses: docker/build-push-action@v2 with: context: . push: true tags: myapp:${{ github.sha }} cache-from: type=registry,ref=myapp:buildcache cache-to: type=registry,ref=myapp:buildcache,mode=maxHow it works:
- First build: Build all layers, push to registry
buildcachetag contains all intermediate layers- Second build: Download
buildcache, reuse all layers - Only rebuild changed layers
Benefit: Fast builds across CI jobs and developers.
BuildKit Cache Mounts
Mount cache directory that persists across builds:
FROM golang:1.20RUN --mount=type=cache,target=/go/pkg/mod go mod download -xRUN --mount=type=cache,target=/root/.cache/go-build go build -o app .Cache directories (/go/pkg/mod, /root/.cache/go-build) are mounted from BuildKit's cache (persists across builds).
Benefit: Module downloads and build caches are reused.
Image Promotion Model
Promote images from dev through staging to production without rebuilding. This approach ensures that the exact image you tested is the one you deploy to production.
Promotion Flow
Images flow through three environments sequentially. In the development environment, the image is built, tested with automated tests, tagged as myapp:dev, and pushed to the dev registry. After manual approval or an automated trigger based on policies, the image moves to the staging environment where it is pulled and run through integration tests against real databases and services, tagged as myapp:staging, and pushed to the staging registry. Following another approval step (often by the product owner or platform team), the image is promoted to the production environment where it is pulled, smoke tests are run, multiple tags are applied (myapp:v1.0.0, myapp:latest, myapp:prod), and it is pushed to the production registry and deployed to running clusters.
The critical insight is that the same image artifact moves through the pipeline unchanged. There is no rebuilding at each stage. If dev tests pass, staging tests pass (assuming staging environment is similar to dev). If staging tests pass, production will work (assuming production is similar to staging). The image is immutable throughout the promotion process.
Promotion in Practice
# CI pipelinestages: - build - test-dev - promote-to-staging - test-staging - promote-to-prod build: script: - docker build -t myapp:$CI_COMMIT_SHA . - docker tag myapp:$CI_COMMIT_SHA myapp:dev - docker push registry-dev.example.com/myapp:dev promote_to_staging: when: manual # Requires approval script: - docker pull registry-dev.example.com/myapp:dev - docker tag myapp:dev myapp:staging - docker push registry-staging.example.com/myapp:staging promote_to_prod: when: manual # Requires approval script: - docker pull registry-staging.example.com/myapp:staging - docker tag myapp:staging myapp:v1.0.0 - docker push registry-prod.example.com/myapp:v1.0.0Benefit: Reproducible. You promote the exact artifact you tested, not a rebuilt artifact.
GitOps and Images
In a GitOps pipeline, the Git repository is the single source of truth.
Traditional Deployment
In traditional deployment, CI builds an image, pushes it to the registry, and then requires manual intervention. An administrator manually deploys version v1.0.0 by running kubectl apply with an updated deployment.yaml file containing the new image tag. The cluster then updates with the new pods.
GitOps Deployment
In GitOps deployment, the process is fully automated. CI builds an image and pushes it to the registry, then automatically updates the Deployment manifest in Git with the new image tag. A GitOps agent (ArgoCD or Flux) continuously watches the Git repository, detects the change, and automatically syncs the cluster to match the Git state. The cluster updates automatically without manual intervention.
Example: ArgoCD
# Git repository: infra/deployment.yamlapiVersion: apps/v1kind: Deploymentmetadata: name: myappspec: template: spec: containers: - name: myapp image: registry.example.com/myapp:1.0.0 ← This value is source of truthCI Job:
# Update Git with new imageupdate_git: script: - git clone https://github.com/example/infra - sed -i "s|image:.*|image: registry.example.com/myapp:$CI_COMMIT_SHA|" infra/deployment.yaml - git add infra/deployment.yaml - git commit -m "Deploy myapp:$CI_COMMIT_SHA" - git push https://token@github.com/example/infraArgoCD: Watches the Git repo, Detects change, Compares Git state with cluster state, and Automatically applies the change.
Benefit: Git is the source of truth. Deployment history is tracked in Git. Easy to rollback (git revert).
Next Steps
Container images are now your CI/CD artifact. Understanding the full pipeline is critical:
Key takeaways: The container image is now your single deployable artifact, replacing traditional binaries and JAR files. The philosophy of build once, test once, deploy many times ensures you deploy the exact artifact you verified. Your registry—whether Docker Hub, Artifactory, ECR, or GCR—serves as central storage for all your images. Tag images using semantic versioning, git SHA, or multi-level tagging strategies to uniquely identify images. Always scan for vulnerabilities using tools like Trivy or Snyk before deploying to production. Sign images using Cosign to prove authenticity and prevent tampering. Use multi-stage builds in your Dockerfiles to minimize image size by keeping build tools out of the final image. Implement aggressive caching strategies to speed up builds and reduce iteration time. Promote tested images through your environments—dev to staging to production—without rebuilding. Embrace GitOps principles where Git serves as your source of truth and images are deployed automatically when Git state changes.
Practical next steps to implement container-native CI/CD: Set up a multi-stage Dockerfile for your application and measure the size reduction compared to single-stage builds. Add image scanning to your CI pipeline so every image is evaluated before it can be pushed to the registry. Implement image signing using Cosign to cryptographically verify images. Create a promotion pipeline that moves images from dev to staging to production with appropriate approval gates. Configure docker buildx with remote caching enabled so subsequent builds reuse layers across different machines. Finally, implement GitOps using tools like ArgoCD or Flux so image deployments are automatic when you update Git.
Key Concepts Summary: A container-native pipeline flows Code → Image → Test → Scan → Sign → Deploy, with the image as the central deployable artifact. The image is your single artifact—no separate binaries or config files. Building once and deploying many times ensures reproducibility. The registry provides central image storage accessible to all systems. Tagging with semantic versions, git SHAs, or multi-level tags creates unique image identification. Scanning with tools like Trivy and Snyk detects vulnerabilities before production. Cosign signing proves image authenticity. Multi-stage builds keep development tools out of final images. Caching through layer cache, remote cache, and cache mounts dramatically speeds up builds. Promotion moves tested images through environments without rebuilding. GitOps makes Git the source of truth with automatic deployment when Git changes.
