Skip to content

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

Core Concept LearningJuly 4, 20266 min readUpdated July 21, 2026

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. Git Branching Strategy explains how branch shape affects that pipeline.

This article covers GitHub Actions workflow patterns valid as of July 2026: workflow anatomy, build and test, container publishing, deployment strategies, and secrets. Action releases and runner images move; verify current action inputs and pin reviewed production dependencies before copying these teaching examples. Place the workflow in the wider operating path with What Full Stack Really Means.

Commit to production: the full CI/CD pipeline
Commit to production: the full CI/CD pipeline

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, jobs, and sequential steps within each job. Jobs run on GitHub-hosted or self-hosted runners.

Actions are executable third-party dependencies. A major tag such as @v4 is convenient for a readable teaching sample but mutable; it does not automatically deliver a security fix unless a later run resolves to a moved tag. For production, resolve a reviewed release from the action's canonical repository, pin its verified full commit SHA, keep the release tag in a comment, and use Dependabot or review to propose updates. GitHub's official guidance is docs.github.com/en/actions/reference/security/secure-use (see “Pin actions to a full-length commit SHA”); workflow syntax is at docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax.

Anatomy of a GitHub Actions workflow file
Anatomy of a GitHub Actions workflow file

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).
  • setup-node cache: npm caches npm's download cache; npm ci still rebuilds node_modules on each fresh runner.
  • Pin actions to verified full commit SHAs for immutability; major tags are mutable update channels.
  • Concurrency: cancel in-flight runs on the same branch when a new commit is pushed.
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
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"                  # caches npm's package-manager cache, not node_modules31 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 can be a Docker image. Tagging it with the full Git SHA makes the artifact traceable to the code that produced it.

Multi-stage Dockerfiles separate build tools from the runtime filesystem and can reduce size and attack surface. The result depends on base image, native libraries, framework output, and whether production dependencies are pruned; measure with docker image ls instead of targeting a universal size.

Multi-stage build, then push the tagged image
Multi-stage build, then push the tagged image

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) can reduce rebuild time when relevant layers are reusable; record cold and warm timings.
  • 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).
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# Measure this image in your own build; size depends on inputs.
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# Compare with: docker image ls17 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 images tagged with the Git SHA, push only after tests pass, and measure cache effectiveness on your workload.

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.

Three deployment strategies: rolling, blue-green, canary
Three deployment strategies: rolling, blue-green, canary

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.
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
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          set -e15          kubectl set image deployment/api \16            api=ghcr.io/${{ github.repository }}:${{ github.sha }}17 18          if ! kubectl rollout status deployment/api --timeout=5m; then19            kubectl rollout undo deployment/api20            kubectl rollout status deployment/api --timeout=5m21            exit 122          fi23 24          if ! curl --fail --retry 5 --retry-all-errors \25            https://api.example.com/health; then26            kubectl rollout undo deployment/api27            kubectl rollout status deployment/api --timeout=5m28            exit 129          fi30 31---32# Kubernetes deployment manifest33apiVersion: apps/v134kind: Deployment35spec:36  replicas: 337  strategy:38    type: RollingUpdate39    rollingUpdate:40      maxUnavailable: 0           # never below 3 pods during deploy41      maxSurge: 1                 # allow 4 pods temporarily42  template:43    spec:44      containers:45        - name: api46          readinessProbe:         # Kubernetes won't route traffic until healthy47            httpGet:48              path: /health49              port: 300050            initialDelaySeconds: 551            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.

For cloud deployment credentials specifically, skip GitHub Secrets entirely and use OpenID Connect (OIDC) instead. Rather than storing a long-lived cloud access key that could leak, the job presents a signed token identifying its exact repository, ref, and workflow; the cloud provider checks that token against a trust policy and issues a scoped credential that expires in minutes. There is no static key sitting in Settings → Secrets for a leaked log line, a compromised dependency, or a departed contributor to exploit.

Zoom into one GitHub Actions deployment using short-lived cloud credentials
Zoom into one GitHub Actions deployment using short-lived cloud credentials

Quick reference

  • GitHub Secrets: encrypted, injected as env vars, masked in logs. Free, good for CI/CD secrets.
  • OIDC for cloud deploys: the job exchanges a signed identity token for a short-lived role — no static cloud key to store or leak.
  • 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).
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
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 values, OIDC for cloud deployment credentials so no static key exists to leak, and a secret store (Secrets Manager, Vault) for runtime configuration.

Key takeaway

A useful pipeline produces evidence: the same locked install, tests, immutable artifact identity, deployment status, smoke result, and rollback result on every run. Automate only as far as your tests and recovery path justify; production approvals can remain an intentional control.

Practice (30 min): create a tiny Node app with /health, a failing unit-test branch, and the CI workflow above. Open a pull request and prove the failing test blocks the build job. Fix it, merge to a non-production environment, verify the pushed image tag equals github.sha, then deploy an image whose /health returns 503 and confirm the job runs rollout undo, waits for recovery, and exits non-zero.

Share:

Related Articles

Deploying web applications manually or relying on basic git-push hooks introduces critical vulnerabilities into producti

Read

Containers are the foundation of modern cloud deployment, but default container images often ship with bloated Linux OS

Read

While Gemini CLI excels as an interactive terminal partner, its true power for DevOps and platform teams lies in Non-Int

Read

Keep learning

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