How to build a CI/CD pipeline from scratch with GitHub Actions, Docker, and Kubernetes
From first commit to automated production deploy.
Iago Mussel
CEO & Founder
Most CI/CD tutorials show you a happy path that works in isolation and breaks the moment you touch anything real.
This one shows you the full pipeline — including the decisions that matter, the parts that are easy to get wrong, and what to skip when you’re starting from zero.
Stack: GitHub Actions for orchestration, Docker for packaging, Kubernetes for deployment. Here’s exactly what I’d build.
What you’re building
A pipeline that:
- Runs tests on every pull request
- Builds a Docker image on merge to main
- Pushes the image to a registry
- Deploys to Kubernetes with zero downtime
- Validates the deploy before declaring success
That’s the baseline. Everything else is optimization you can add later.
Start with the repository structure
Before writing any workflow YAML, make sure your repo has what the pipeline actually needs.
project/
├── src/
├── tests/
├── Dockerfile
├── k8s/
│ ├── deployment.yaml
│ ├── service.yaml
│ └── ingress.yaml
└── .github/
└── workflows/
├── ci.yml
└── deploy.yml
Two workflow files. ci.yml handles testing — fires on every PR. deploy.yml handles building and deploying — fires on merge to main. Keeping them separate means a failing test doesn’t prevent you from understanding deploy behavior, and you can iterate on each independently.
The CI workflow
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Run linter
run: npm run lint
Keep CI fast. Under 5 minutes is the target. Over 10 minutes and developers start skipping it or merging before it finishes — which defeats the whole point.
If your tests are slow, that’s a test architecture problem. Don’t accept slow CI as normal.
The Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
Two stages. The builder installs and compiles. The runtime image only contains what’s needed to run the app. Smaller image, smaller attack surface.
The non-root user at the end is not optional in production. Running containers as root is a security risk that’s trivial to avoid.
The deploy workflow
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure kubectl
uses: azure/setup-kubectl@v3
- name: Set kubeconfig
run: echo "${{ secrets.KUBECONFIG }}" | base64 -d > /tmp/kubeconfig
- name: Update image tag
run: |
sed -i "s|IMAGE_TAG|${{ needs.build-and-push.outputs.image-tag }}|g" k8s/deployment.yaml
- name: Apply deployment
run: kubectl apply -f k8s/ --kubeconfig /tmp/kubeconfig
- name: Wait for rollout
run: kubectl rollout status deployment/app --timeout=5m --kubeconfig /tmp/kubeconfig
That last rollout status step is the important one. The workflow fails if the new pods don’t become healthy within 5 minutes. A bad deploy fails the pipeline — it doesn’t silently succeed and leave you wondering what happened.
The Kubernetes deployment
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
spec:
replicas: 2
selector:
matchLabels:
app: web
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: web
spec:
containers:
- name: app
image: IMAGE_TAG
ports:
- containerPort: 3000
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
maxUnavailable: 0 is what gives you zero-downtime rolling updates. Old pods stay up until the new ones are ready.
The readiness probe is equally important. Without it, Kubernetes routes traffic to pods before they’re actually ready to handle requests. That causes errors during deploys that look like bugs but are really just timing issues.
Secrets setup
Two things to configure in GitHub repository settings → Secrets:
KUBECONFIG: Base64 your kubeconfig withcat ~/.kube/config | base64
Docker registry auth uses GITHUB_TOKEN, which GitHub provides automatically for GitHub Container Registry. No additional setup needed.
What to add next
This baseline gets you from PR to zero-downtime production deploy with validation. Once it’s stable, the highest-value additions are:
- Slack/Discord notifications on deploy success or failure — know immediately without watching the Actions tab
- Environment-specific workflows — staging on PR merge, production on tag push
- Secret scanning in CI — catch leaked credentials before they ship
- Image vulnerability scanning with Trivy before the push step
Build the baseline. Run it for two weeks. Then add the rest.
I work with teams building production systems and developer tooling. If this topic resonates, you can find more of my work at https://huntermussel.com.
Share
Related articles
How a modern CI/CD pipeline works — and where AI actually fits
A clear breakdown of modern CI/CD pipelines, what each stage does, and where AI is genuinely useful versus just hype.
The problem with migrating from Jenkins to GitHub Actions in the middle of critical systems
Migrating CI/CD for systems that can't afford downtime or broken deploys requires more than rewriting Jenkinsfiles. Here's the strategy that works.
Jenkins or GitHub Actions? How to choose the right CI/CD tool for your context
An honest comparison of Jenkins and GitHub Actions — including when Jenkins is still the right answer and when it's technical debt you don't need.