Skip to content
Back to blog
CI/CDGitHub ActionsDevOpsDockerDeployment

CI/CD Pipelines with GitHub Actions: From Commit to Production

July 4, 202613 min read

Manual deployments are one of the highest-risk activities in software engineering. A developer SSHes into a production server, runs commands from memory, makes a mistake, and causes an outage at 5pm on a Friday. Continuous Integration and Continuous Deployment (CI/CD) automates the path from a Git commit to a running system — running tests, building containers, and deploying to production without manual intervention.

This article covers how to build a real CI/CD pipeline with GitHub Actions: the anatomy of a workflow file, a build-and-test pipeline, Docker image builds and pushes to a container registry, deployment strategies (rolling, blue-green, canary), and secrets management. By the end you'll have a pipeline that deploys on every merge to main.

CI vs CD: What the Letters Actually Mean

Continuous Integration (CI) is the practice of merging code changes frequently and automatically validating them with a build and test suite. The goal is to catch integration bugs — where two developers' changes conflict — within minutes, not during a quarterly release crunch. CI runs on every pull request and merge.

Continuous Delivery (CD) extends CI by automatically deploying validated code to a staging environment. Deployment to production still requires a manual approval step. Continuous Deployment (also CD) removes that manual step — every green build on main goes to production automatically. Most teams target Continuous Delivery (auto-deploy to staging, manual gate to production) and move to Continuous Deployment as confidence in their test suite grows.

Quick reference

  • CI: code is automatically built and tested on every push. Merge only when green.
  • Continuous Delivery: CD deploys to staging automatically. Production deploy is manual but always ready.
  • Continuous Deployment: every green main build goes directly to production — no human approval.
  • Trunk-based development: everyone commits to main (or short-lived branches < 1 day). Required for true CD.
  • Feature flags: use flags to deploy dark code to production without activating it — decouples deploy from release.
  • Mean time to recovery (MTTR): small, frequent deploys reduce the blast radius of failures and speed recovery.

Remember this

CI ensures every commit is verified. CD ensures it's deployable. Start with Continuous Delivery (auto-staging, manual production) and graduate to Continuous Deployment.

GitHub Actions Anatomy

A GitHub Actions workflow is a YAML file in .github/workflows/. A workflow has triggers (when it runs), jobs (parallel or sequential units of work), and steps (sequential shell commands or actions within a job). Jobs run on runners — GitHub-hosted VMs (ubuntu-latest, windows-latest, macos-latest) or self-hosted runners in your infrastructure.

Actions are reusable steps published on the GitHub Marketplace. actions/checkout fetches your code. actions/setup-node installs Node. docker/login-action authenticates to a registry. Using published actions for common tasks reduces boilerplate and inherits community security fixes.

Quick reference

  • on: defines triggers — push, pull_request, schedule (cron), workflow_dispatch (manual).
  • jobs: each job runs on a fresh VM in parallel by default. Use needs: [job-name] for sequencing.
  • steps: run sequentially within a job. Each step is a uses: (action) or run: (shell command).
  • services: spin up Docker containers alongside the job (database, Redis, mock servers).
  • cache: save node_modules/pip cache between runs — turns 3min installs into 10s.
  • Concurrency: cancel in-flight runs on the same branch when a new commit is pushed.
Before
Naive script without Actions — fragile, hard to maintain
1# Fragile bash script run manually on a server2#!/bin/bash3git pull origin main4npm install5npm run build6pm2 restart app7# No tests, no rollback, no audit trail
After
GitHub Actions workflow — automated, auditable, repeatable
1# .github/workflows/ci.yml2name: CI3 4on:5  push:6    branches: [main]7  pull_request:8    branches: [main]9 10jobs:11  test:12    runs-on: ubuntu-latest13 14    services:15      postgres:                         # spin up a real database for integration tests16        image: postgres:1617        env:18          POSTGRES_PASSWORD: postgres19        options: >-20          --health-cmd pg_isready21          --health-interval 10s22          --health-timeout 5s23 24    steps:25      - uses: actions/checkout@v4       # fetch code26 27      - uses: actions/setup-node@v428        with:29          node-version: "22"30          cache: "npm"                  # cache node_modules for speed31 32      - run: npm ci                     # reproducible install33 34      - run: npm run lint               # static analysis35 36      - run: npm run typecheck          # TypeScript errors37 38      - run: npm test                   # unit + integration tests39        env:40          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test

Remember this

One YAML file per workflow. Separate jobs for test, build, and deploy. Use services for database integration tests. Cache dependencies.

Docker Build and Container Registry Push

After tests pass, the deploy artifact is a Docker image. Building and pushing it to a container registry (GitHub Container Registry, AWS ECR, Google Artifact Registry) is the bridge between CI and CD. The image is tagged with the Git SHA — a short commit hash — making every image traceable back to the exact code it was built from.

Multi-stage Dockerfiles separate the build environment (compiler, dev dependencies) from the runtime image (only what's needed to run the app). A Node.js app's final image should be ~100MB, not 1GB with all build tools included.

Quick reference

  • Tag images with git SHA (github.sha) — every image traceable to exact code. Also tag latest for convenience.
  • Multi-stage builds: builder stage has compiler/tools, runtime stage has only the binary. Smaller, more secure.
  • GitHub Container Registry (ghcr.io): free for public, included with GitHub. No extra credentials needed.
  • Layer caching (type=gha): caches Docker layers between runs. Turns 5min builds into 30s on cache hit.
  • Scan images: add trivy or grype scanning step to catch CVEs before pushing.
  • Never bake secrets into images. Use runtime environment variables or secret stores (AWS Secrets Manager, Vault).
Before
Single-stage Dockerfile — bloated production image
1# Single-stage — includes all dev dependencies and build tools2FROM node:223WORKDIR /app4COPY package*.json ./5RUN npm install        # includes devDependencies6COPY . .7RUN npm run build8CMD ["node", "dist/index.js"]9# Image size: ~1.2GB
After
Multi-stage + GitHub Actions push to registry
1# Multi-stage Dockerfile — lean production image2FROM node:22-slim AS builder3WORKDIR /app4COPY package*.json ./5RUN npm ci6COPY . .7RUN npm run build8 9FROM node:22-slim AS runtime10WORKDIR /app11RUN addgroup --system app && adduser --system --ingroup app app12COPY --from=builder /app/dist ./dist13COPY --from=builder /app/node_modules ./node_modules14USER app                         # don't run as root15CMD ["node", "dist/index.js"]16# Image size: ~120MB17 18---19# .github/workflows/ci.yml (build job, runs after test)20  build:21    needs: [test]                  # only runs if tests pass22    runs-on: ubuntu-latest23    outputs:24      image-tag: ${{ steps.meta.outputs.tags }}25 26    steps:27      - uses: actions/checkout@v428 29      - uses: docker/login-action@v330        with:31          registry: ghcr.io32          username: ${{ github.actor }}33          password: ${{ secrets.GITHUB_TOKEN }}   # built-in, no setup34 35      - name: Build and push36        uses: docker/build-push-action@v537        with:38          context: .39          push: true40          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}41          cache-from: type=gha                    # GitHub Actions cache layer42          cache-to: type=gha,mode=max

Remember this

Build multi-stage Docker images tagged with the Git SHA. Push to a registry only after tests pass. Use layer caching to keep build times under 2 minutes.

Deployment Strategies: Rolling, Blue-Green, Canary

How you deploy matters as much as what you deploy. Rolling deployments replace instances one at a time — new pods come up, old pods come down, traffic gradually shifts. This means during deployment, old and new versions run simultaneously, which requires backward compatibility for any shared database changes.

Blue-green deployments run two identical environments (blue and green). You deploy to the idle environment, run smoke tests, then flip traffic. Rollback is instant — flip traffic back. Canary deployments shift a small percentage of traffic (1–5%) to the new version first, monitor error rates and latency, then gradually increase. Each strategy trades deployment speed for risk reduction.

Quick reference

  • Rolling: gradual, requires API backward compatibility during rollout. Default in Kubernetes.
  • Blue-green: instant rollback, doubles infrastructure cost during deploy. Good for risky migrations.
  • Canary: partial traffic to new version, monitor metrics, increase gradually. Best for high-traffic services.
  • Readiness probes: Kubernetes waits for /health to return 200 before routing traffic. Essential for zero-downtime.
  • Rollback: kubectl rollout undo deployment/api — reverts to previous ReplicaSet in seconds.
  • Feature flags instead of canary: deploy fully, activate for 1% of users via flags. Separates deploy from release.
Before
Manual deploy — risky, no rollback
1# Manual deployment via SSH — risky and slow2ssh deploy@prod-server.example.com3cd /app4git pull origin main5npm install --production6pm2 restart app7# No rollback plan8# No health check9# All traffic instantly on new version10# Any startup error = downtime
After
Rolling deploy via GitHub Actions to Kubernetes
1# .github/workflows/deploy.yml2  deploy:3    needs: [build]4    runs-on: ubuntu-latest5    environment: production           # requires manual approval in GitHub6 7    steps:8      - uses: azure/k8s-set-context@v39        with:10          kubeconfig: ${{ secrets.KUBECONFIG }}11 12      - name: Rolling deploy13        run: |14          kubectl set image deployment/api \15            api=ghcr.io/${{ github.repository }}:${{ github.sha }}16 17          kubectl rollout status deployment/api --timeout=5m18 19      - name: Smoke test20        run: |21          sleep 1022          curl -f https://api.example.com/health || \23            kubectl rollout undo deployment/api  # auto-rollback on failure24 25---26# Kubernetes deployment manifest27apiVersion: apps/v128kind: Deployment29spec:30  replicas: 331  strategy:32    type: RollingUpdate33    rollingUpdate:34      maxUnavailable: 0           # never below 3 pods during deploy35      maxSurge: 1                 # allow 4 pods temporarily36  template:37    spec:38      containers:39        - name: api40          readinessProbe:         # Kubernetes won't route traffic until healthy41            httpGet:42              path: /health43              port: 300044            initialDelaySeconds: 545            periodSeconds: 5

Remember this

Use rolling deploys with readiness probes for zero-downtime. Add a smoke test step that auto-rolls back on failure. Blue-green for database migrations; canary for high-risk features.

Secrets and Environment Management

Secrets in code are one of the most common security vulnerabilities. API keys, database passwords, and JWT secrets hardcoded in source code get committed to version history, exposed in CI logs, and leaked by any developer who clones the repo. The rule is absolute: secrets never live in the repository.

GitHub Secrets (Settings → Secrets) are encrypted at rest and injected as environment variables into workflows. They're masked in logs — if a secret value appears in a log line, GitHub replaces it with ***. For production systems, a dedicated secret store (AWS Secrets Manager, HashiCorp Vault, Azure Key Vault) provides rotation, audit logging, and fine-grained access control.

Quick reference

  • GitHub Secrets: encrypted, injected as env vars, masked in logs. Free, good for CI/CD secrets.
  • AWS Secrets Manager / Azure Key Vault: rotation support, audit logging, IAM-based access. For production runtime.
  • HashiCorp Vault: self-hosted, dynamic secrets (generates temporary DB credentials on demand).
  • Commit .env.example with structure but no values. Add .env to .gitignore.
  • Scan for secrets in CI: add gitleaks or truffleHog as a pre-merge check to catch accidental commits.
  • Rotate secrets regularly. Use separate secrets per environment (dev/staging/prod).
Before
Secrets in code and environment files committed to repo
1# .env committed to repo (dangerous!)2DATABASE_URL=postgresql://prod:S3cr3t@db.example.com/app3STRIPE_SECRET_KEY=sk_live_abc1234JWT_SECRET=mysecretkey5 6# Dockerfile with hardcoded secret (visible in image layers)7ENV STRIPE_SECRET_KEY=sk_live_abc123
After
GitHub Secrets + runtime secret store
1# GitHub Actions — secrets injected at runtime, masked in logs2  deploy:3    steps:4      - name: Deploy5        env:6          DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}7          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}8        run: |9          kubectl create secret generic app-secrets \10            --from-literal=DATABASE_URL="$DATABASE_URL" \11            --from-literal=STRIPE_SECRET_KEY="$STRIPE_SECRET_KEY" \12            --dry-run=client -o yaml | kubectl apply -f -13 14# .env.example committed to repo (no real values, just structure)15DATABASE_URL=postgresql://user:password@localhost:5432/myapp16STRIPE_SECRET_KEY=sk_test_...17JWT_SECRET=...18 19# Runtime: fetch from secret store (Node.js example)20import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";21 22const client = new SecretsManagerClient({ region: "us-east-1" });23const { SecretString } = await client.send(24  new GetSecretValueCommand({ SecretId: "prod/myapp" })25);26const secrets = JSON.parse(SecretString!);

Remember this

Never commit secrets to a repository. Use GitHub Secrets for CI/CD. Use a secret store (Secrets Manager, Vault) for runtime configuration. Scan commits for leaked secrets.

Key takeaway

Share:

A well-built CI/CD pipeline does three things: it catches bugs before they reach users (CI), it makes deploys so boring that no one is scared to do them (CD), and it makes rollback fast enough that a bad deploy doesn't become an outage (deployment strategies).

Start simple: a GitHub Actions workflow that runs tests on every PR and deploys to staging on merge to main. Add Docker and container registry push when you're ready. Add canary or blue-green when traffic justifies it. The most important step is removing the human from the critical path — not because humans make mistakes, but because manual processes don't run at 2am when that urgent fix needs to ship.

Related Articles

DockerKubernetes

Docker and Kubernetes are often mentioned together, but they solve different problems. Docker packages your application

Read

When a production system breaks at 3am, you need to answer three questions fast: what happened, how bad is it, and where

Read

SOLID is five principles for writing object-oriented code that's easy to extend without breaking existing behavior. They

Read

Explore this topic

Keep learning

Follow a structured path or browse all courses to go deeper.