CI/CD Pipeline From Zero to Production: Docker → Kubernetes → Terraform
A battle-tested guide to building production CI/CD pipelines with Docker, GitHub Actions, Kubernetes, and Terraform. Real configs, real lessons.
On this page
If your deployment process involves SSH-ing into a server and running git pull, this post is your intervention. We need to talk. That approach doesn’t scale, is prone to human error, and frankly, it’s how outages are born. A proper CI/CD (Continuous Integration/Continuous Deployment) pipeline automates the path from code commit to production, making your deployments faster, more reliable, and less terrifying.
This guide is a practical, no-nonsense walkthrough for building a production-grade pipeline in 2026 using a modern, battle-tested stack: Docker, GitHub Actions, Kubernetes, and Terraform.
The stack overview: a bird’s-eye view
Before we dive into the YAML and HCL trenches, let’s look at the architecture. Our goal is to create a seamless flow where code changes trigger a series of automated quality gates and deployments.
Here’s the flow:
- Developer pushes code to a GitHub repository.
- GitHub Actions (CI) triggers a workflow. It builds, tests, and scans the code.
- A Docker Image is built and pushed to a container registry (like AWS ECR).
- ArgoCD (CD), our GitOps tool, detects the new image tag in our Kubernetes configuration repository.
- ArgoCD syncs the change, telling Kubernetes to perform a rolling update, deploying the new version of the application.
- All the underlying infrastructure—the Kubernetes cluster (EKS), VPC, databases (RDS)—is defined as code using Terraform.
Think of it as an automated assembly line for your software.
[Git Push] -> [GitHub Actions: Build, Test, Scan] -> [Push Docker Image to ECR]
|
v
[ArgoCD detects change in Git] <- [Update K8s Manifests]
|
v
[Kubernetes Cluster (EKS)] <---- [Terraform Manages Infrastructure]
- Deploys new image
- Managed by Terraform
Docker: containerize everything
Containers are the foundation of modern CI/CD. They package your application and its dependencies into a single, portable unit. This consistency eliminates the classic “but it works on my machine” problem.
A key technique is the multi-stage build, which keeps your final images lean and secure.
Here’s a Dockerfile for a typical Node.js application:
# ---- Base Stage ----
# Use a specific Node version for reproducibility
FROM node:22-alpine AS base
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --production
# ---- Build Stage ----
# This stage builds our frontend assets
FROM base AS build
COPY . .
RUN npm install
RUN npm run build
# ---- Production Stage ----
# This is the final, lean image we'll deploy
FROM base
COPY --from=build /usr/src/app/dist ./dist
COPY --from=base /usr/src/app/node_modules ./node_modules
COPY . .
# Run security scan with Trivy
RUN apk add --no-cache trivy
RUN trivy fs --exit-code 1 --severity HIGH,CRITICAL .
EXPOSE 3000
CMD ["node", "src/server.js"]
This Dockerfile has three stages. The build stage installs dev dependencies and builds assets, but the final production stage only copies over the necessary artifacts, resulting in a much smaller, more secure image. We also run a trivy scan directly in the build to catch critical vulnerabilities before they ever get pushed.
GitHub Actions: the CI layer
GitHub Actions is our pipeline’s engine. It’s event-driven, most commonly triggered by a git push or pull_request. The workflow is defined in a YAML file inside the .github/workflows directory.
This workflow handles building, testing, scanning, and pushing our Docker image.
# .github/workflows/ci.yml
name: CI-Pipeline
on:
push:
branches:
- main
env:
REGISTRY: "your-account-id.dkr.ecr.us-east-1.amazonaws.com"
IMAGE_NAME: "my-app"
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # Required for OIDC
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::your-account-id:role/github-actions-role
aws-region: us-east-1
- name: Log in to Amazon ECR
uses: aws-actions/amazon-ecr-login@v2
- name: Build, tag, and push image to Amazon ECR
id: build-image
env:
ECR_REGISTRY: ${{ env.REGISTRY }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG .
docker push $ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG
echo "image=$ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG" >> $GITHUB_OUTPUT
Key features here:
- OIDC Authentication: We’re not using long-lived IAM user secrets.
configure-aws-credentialsuses OpenID Connect to securely request temporary credentials from AWS. This is a massive security win. - Image Tagging: We tag our image with the
github.sha(the commit hash). This ensures every image is unique and traceable. Never, ever use the:latesttag in production. I’ve seen it bring down production twice. Both times on a Friday at 5 PM.
Kubernetes & ArgoCD: the CD layer
Kubernetes is our container orchestrator. It manages our application’s lifecycle, scaling, and health. We define our desired state in YAML manifests.
Here’s a simplified deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app-deployment
spec:
replicas: 3
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
spec:
containers:
- name: my-app
image: your-account-id.dkr.ecr.us-east-1.amazonaws.com/my-app:commit-hash # This gets updated by CI
ports:
- containerPort: 3000
resources:
requests:
memory: "64Mi"
cpu: "250m"
limits:
memory: "128Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /healthz
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
This file is stored in a separate Git repository (a “config repo”). Our CI pipeline’s final step will be to update the image tag in this file.
ArgoCD then takes over. It’s a GitOps tool that continuously compares the live state of our cluster with the desired state in our config repo. When it detects a difference (like our new image tag), it automatically pulls the changes and applies them to the cluster, triggering a safe, rolling update. This pull-based model is more secure and declarative than a traditional push-based CD.
Terraform: infrastructure as code
Manually clicking through a cloud console to set up infrastructure is a recipe for disaster. It’s not repeatable, auditable, or scalable. Terraform solves this by letting you define your infrastructure as code.
Here’s a snippet for creating an EKS (Elastic Kubernetes Service) cluster on AWS:
provider "aws" {
region = "us-east-1"
}
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
// ... other VPC config
}
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "20.0.0"
cluster_name = "my-cluster"
cluster_version = "1.32"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
eks_managed_node_groups = {
one = {
min_size = 1
max_size = 3
desired_size = 2
instance_type = "t3.medium"
}
}
}
terraform {
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "global/s3/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
Key concepts:
- Modules: We use pre-built modules for complex components like VPC and EKS. This keeps our code DRY (Don’t Repeat Yourself).
- State Management: The
backend "s3"block is critical. It tells Terraform to store its state file in an S3 bucket and use a DynamoDB table for locking. This prevents conflicts when multiple engineers runterraform applyat the same time.
Putting it all together
Let’s trace the full journey:
- You push a feature to the
mainbranch. - GitHub Actions starts the CI workflow.
- Your code is tested and a Docker image is built, tagged with the commit hash (e.g.,
.../my-app:a1b2c3d). - The image is pushed to AWS ECR.
- A final step in the CI job checks out the K8s config repo, updates
deployment.yamlwith the new image tag, and pushes the change. - ArgoCD, watching the config repo, sees the new commit.
- ArgoCD applies the change to the EKS cluster. Kubernetes sees the Deployment’s image has changed and initiates a rolling update, safely replacing old pods with new ones.
- Your change is live. Zero manual steps, full audit trail.
Common pitfalls & pro tips
- Secret Leaks: Use tools like
git-secretsand repository scanning. Store secrets in a dedicated manager like AWS Secrets Manager or HashiCorp Vault, not in environment variables. - Image Tags: I’ll say it again: never use
:latest. It’s not idempotent. Use commit hashes or semantic versioning. - Terraform State Locking: Always use a remote backend with locking (like S3 + DynamoDB) to prevent state corruption.
- K8s Resource Limits: Always set CPU and memory requests and limits. Without them, a single runaway pod can crash an entire node.
- CI Speed: Cache dependencies aggressively in your CI pipeline (e.g.,
actions/cachefor npm modules, Docker layer caching). A 20-minute build is a developer productivity killer.
Building a robust CI/CD pipeline is an investment, but the payoff in speed, stability, and developer sanity is immeasurable. Stop the git pull madness. Automate your path to production.
Happy deploying!