CI/CD Pipeline From Zero to Production: Docker → Kubernetes → Terraform

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:

  1. Developer pushes code to a GitHub repository.
  2. GitHub Actions (CI) triggers a workflow. It builds, tests, and scans the code.
  3. A Docker Image is built and pushed to a container registry (like AWS ECR).
  4. ArgoCD (CD), our GitOps tool, detects the new image tag in our Kubernetes configuration repository.
  5. ArgoCD syncs the change, telling Kubernetes to perform a rolling update, deploying the new version of the application.
  6. 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-credentials uses 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 :latest tag 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 run terraform apply at the same time.

Putting it all together

Let’s trace the full journey:

  1. You push a feature to the main branch.
  2. GitHub Actions starts the CI workflow.
  3. Your code is tested and a Docker image is built, tagged with the commit hash (e.g., .../my-app:a1b2c3d).
  4. The image is pushed to AWS ECR.
  5. A final step in the CI job checks out the K8s config repo, updates deployment.yaml with the new image tag, and pushes the change.
  6. ArgoCD, watching the config repo, sees the new commit.
  7. 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.
  8. Your change is live. Zero manual steps, full audit trail.

Common pitfalls & pro tips

  • Secret Leaks: Use tools like git-secrets and 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/cache for 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!

Thread

0
⌘/Ctrl+Enter to sendType / for commands · Tab to @mention