본문으로 건너뛰기
Previous
Next
GitHub Actions CI/CD Guide | Workflows· Secrets

GitHub Actions CI/CD Guide | Workflows· Secrets

GitHub Actions CI/CD Guide | Workflows· Secrets

이 글의 핵심

GitHub Actions turns your repository into a CI/CD platform. This guide covers workflow syntax, caching, matrix builds, Docker publishing, environment secrets, and real deployment pipelines — all with copy-paste examples.

GitHub Actions is effectively “CI as code”: YAML files in .github/workflows/ define what runs, when, and on which machines. The mental model is simple once you internalize how workflows, jobs, steps, and runners interact — after that, everything else (matrix strategies, environments, secrets, and caching) is optimization and safety on top of the same building blocks.

Core concepts: workflows, jobs, steps, and runners

A workflow is a single YAML file (for example, ci.yml) triggered by an event: push, pull request, schedule, workflow_dispatch, or workflow_call. A workflow contains one or more jobs. Each job is scheduled on a runner — a fresh virtual machine (GitHub‑hosted or self‑hosted) with a clean workspace unless you reuse artifacts. Jobs run in parallel by default; use needs to form a directed acyclic graph (test → build → deploy).

A step is the smallest unit inside a job. Steps execute in order on the same runner, so they share the working directory, environment variables, and any files produced by previous steps. A step is either a run shell block (bash/pwsh) or a uses reference to a published Action (a reusable step bundle, often a container or composite script). That distinction matters: “checkout code” and “set up Node” are typically Actions; npm test is usually a run step.

Runners come in two broad flavors. GitHub‑hosted runners (ubuntu-latest, windows-latest, macos-latest, or larger SKU variants) are the default: predictable images, preinstalled software, and billing tied to minutes and storage for your plan. Self‑hosted runners run on your hardware or VPC — useful for private networks, custom kernels, or GPUs — but you own patching, security, and queuing. In production systems I have worked on, we kept GitHub‑hosted for open-source–style test matrices and self‑hosted only where network isolation was non‑negotiable.

Workflow basics: a minimal end‑to‑end example

The following is a small but complete pipeline: install, test, and build. Note actions/setup-node with cache: npm — you get dependency caching with almost no extra YAML.

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm          # Built-in npm cache

      - run: npm ci
      - run: npm test
      - run: npm run build

Triggers, path filters, and manual deploys

Use narrow triggers to avoid burning minutes on doc‑only or asset‑only commits. paths and paths-ignore are the first line of defense. Pair them with workflow_dispatch when humans should choose an environment; keep input descriptions short and in English for teammates who do not read Korean.

on:
  # Push to specific branches
  push:
    branches: [main]
    paths:                    # Only run if these paths changed
      - 'src/**'
      - 'package.json'
    paths-ignore:
      - '**.md'

  # PRs targeting main
  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened]

  # Scheduled (cron)
  schedule:
    - cron: '0 2 * * 1'     # Every Monday at 2am UTC

  # Manual trigger
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment for the deployment run.'
        required: true
        default: 'staging'
        type: choice
        options: [staging, production]

  # Trigger from another workflow
  workflow_call:
    inputs:
      version:
        type: string
        required: true

Production habit: in one team I was on, we split “fast checks” (lint + typecheck on pull_request with paths filters) from “nightly” heavy integration tests (schedule) so developers never waited five minutes for Markdown edits. That single change reduced perceived CI latency more than any micro‑optimization in Jest.

Jobs, parallelism, and deployment gates

Dependencies between jobs are expressed with needs. Add if on a job to gate production deploys to main or to protected environments.

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

  build:
    runs-on: ubuntu-latest
    needs: test              # Wait for test to pass
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build

  deploy:
    runs-on: ubuntu-latest
    needs: [test, build]     # Wait for both
    if: github.ref == 'refs/heads/main'   # Only on main branch
    environment: production
    steps:
      - run: echo "Deploying..."

Matrix strategies: multi‑version and cross‑OS testing

For libraries and shared packages, a matrix is how you test multiple runtimes in parallel. fail-fast: false keeps the signal from every cell even when one Node version is red, which is invaluable when you are triaging a genuine compatibility bug versus a flaky test.

jobs:
  test:
    runs-on: ${{ matrix.os }}

    strategy:
      fail-fast: false       # Don't cancel other jobs if one fails
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node: [18, 20, 22]
        exclude:
          - os: windows-latest
            node: 18

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci && npm test

Python teams often add python-version and tox or nox; Go teams matrix over 1.20, 1.21, and 1.22 with a module cache key that includes go.sum. The pattern is the same: each matrix dimension multiplies the number of runners — be intentional about the cost (see the section on cost below).

Secrets, variables, and environments

Repository secrets are available as ${{ secrets.NAME }} and are masked in logs when echoed accidentally — but be careful: base64, JSON fragments, and derived values can still leak if you echo the wrong thing. Environment secrets (bound to a named environment: production on a job) add approval gates, branch restrictions, and separate secret namespaces per environment.

Variables (non‑secret) live under Settings → Secrets and variables → Actions → Variables and are referenced as ${{ vars.MY_VALUE }} on newer runtimes, or env in legacy patterns.

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production   # Use environment-level secrets

    env:
      NODE_ENV: production    # Job-level non-secret

    steps:
      - name: Deploy
        env:
          API_KEY: ${{ secrets.API_KEY }}          # From repo or env secrets
          DB_URL: ${{ secrets.PROD_DB_URL }}       # From environment secrets
          GITHUB_SHA: ${{ github.sha }}            # Built-in context
        run: |
          echo "Deploying commit $GITHUB_SHA"
          ./deploy.sh
# Environment protection rules (in GitHub UI):
# Settings → Environments → production
#   Required reviewers: [team members]
#   Wait timer: 5 minutes
#   Deployment branches: main only

I once saw a team store production and staging API keys in the same repository secret namespace “for simplicity.” A mis‑typed job conditional pushed a canary to prod. The fix was not more YAML — it was environments with branch rules and different secret sets so mistakes became hard instead of “oops.”

Caching: dependencies, build caches, and artifacts

Dependency caches (npm, pnpm, yarn, pip, cargo, go modules) save minutes on every run. actions/setup-node’s cache: npm is the fastest win for Node. For monorepos, sometimes you need explicit actions/cache with a key that hashes lockfiles in subpackages.

Build layer caches for Docker (BuildKit / cache-from / cache-to: type=gha) prevent rebuilding identical layers. Build artifacts (actions/upload-artifactactions/download-artifact) pass dist/, test reports, or SBOMs between jobs without committing binaries to git.

# Node.js — built-in cache via setup-node
- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: npm

- run: npm ci

# Python
- uses: actions/setup-python@v5
  with:
    python-version: '3.12'
    cache: pip

- run: pip install -r requirements.txt

# Manual cache control
- uses: actions/cache@v4
  with:
    path: |
      ~/.npm
      .next/cache
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

Personal rule of thumb: if a job spends more than ~30% of its time in “install” or “compile unchanged bits,” you are under‑caching or under‑splitting your jobs. Fix caching first, then think about sharding tests.

Conditional execution, filters, and guardrails

if is available on both jobs and steps. Common expressions include github.event_name == 'pull_request', github.ref == 'refs/heads/main', and contains(github.event.head_commit.message, '[skip ci]') (use sparingly — it is easy to abuse). For forked PRs, if: github.event.pull_request.head.repo.fork == false can skip deploy jobs that need secrets, while still running tests in the fork context with limited permissions.

jobs:
  security-scan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/scan.sh

  deploy:
    if: >
      github.ref == 'refs/heads/main' &&
      github.event_name == 'push'
    needs: [test, build]
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: echo "main-only deploy"

Concurrency prevents stacked deploys and stacked heavy jobs on the same branch.

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

I enable cancel-in-progress for test workflows on feature branches, but I am cautious with production release workflows where partial runs can leave external state half‑mutated. Know what your script does before you cancel it mid‑flight.

Reusable workflows and composable CI

Reusable workflows (on: workflow_call) let a platform team own “the blessed Node test job” and let product repos call it with a short caller file. secrets: inherit passes secrets from the parent when appropriate; for third‑party repos, you would instead declare explicit secrets inputs.

# .github/workflows/reusable-test.yml
on:
  workflow_call:
    inputs:
      node-version:
        type: string
        default: '22'
    secrets:
      NPM_TOKEN:
        required: false

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
      - run: npm ci && npm test
# .github/workflows/ci.yml — call the reusable workflow
jobs:
  test:
    uses: ./.github/workflows/reusable-test.yml
    with:
      node-version: '22'
    secrets: inherit

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying..."

Cost optimization: minutes, storage, and matrix discipline

  • Narrow triggers with paths and paths-ignore so docs do not re‑run a ten‑job matrix.
  • Cache aggressively (dependencies + Docker GHA cache) to avoid repeating work.
  • Right‑size runners: the largest hosted SKU is not free — profile before you upgrade.
  • Matrix explosion: 3 OSes × 3 Node versions = nine billable runs per push; that is great for a library, expensive for a tiny internal app.
  • Artifacts: set retention-days and avoid uploading multi‑gig bundles “just in case.”
  • Self‑hosted runners: watch OS patching and do not let long‑lived workers become secret sprawl zones.

A pattern that worked in a previous company: we ran one wide matrix on main after merge (full confidence) and a reduced matrix on PRs (default Linux + LTS Node) to keep contributor feedback fast and bills predictable.

Production war stories (what actually happens)

Story 1 — flaking tests masked as infra failures
A pipeline looked “randomly” red. It turned out ubuntu-latest image updates and parallel Jest made order‑dependent tests fail. The lesson: pin the runner label when you are debugging, or use a container with a fixed base image. After stabilizing tests, we went back to rolling ubuntu-latest for security patches.

Story 2 — cache poisoning confusion
A bad cache key once restored a .next folder across branches with incompatible NEXT_PUBLIC_* values. The UI looked right locally but not in the preview. We fixed the key to include both lockfile and env fingerprint, and we cleared caches once. Lesson: your cache key must change when the outputs of the step depend on data not in the dependency graph you hashed.

Story 3 — environment approvals saved us
A fat‑fingered git push would have deployed a debug build. Because production required required reviewers on the environment and workflow_dispatch for rollbacks, we caught it before customers saw it. Lesson: if deploy is scary, your YAML should be boring; put human gates in the Environments UI, not in Slack trust.

Story 4 — third‑party Actions pinning
We moved from floating @v3 to pinned commit SHAs for Actions that access secrets, after a supply‑chain discussion. The trade‑off: slightly more maintenance when bumping, much clearer audit trails.

End‑to‑end Node.js example with a database service

This pattern mirrors a real API repo: start Postgres, migrate, test with coverage, and deploy only on main. Notice test isolation: credentials are for CI only, not production.

# .github/workflows/ci-cd.yml
name: CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: testpassword
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci

      - name: Run migrations
        env:
          DATABASE_URL: postgresql://postgres:testpassword@localhost:5432/testdb
        run: npm run db:migrate

      - name: Run tests
        env:
          DATABASE_URL: postgresql://postgres:testpassword@localhost:5432/testdb
          JWT_SECRET: test-secret
        run: npm test -- --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        if: github.ref == 'refs/heads/main'

  deploy:
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main'
    environment: production

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to production
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
        run: |
          echo "$DEPLOY_KEY" | base64 -d > /tmp/deploy_key
          chmod 600 /tmp/deploy_key
          ssh -i /tmp/deploy_key -o StrictHostKeyChecking=no user@server \
            "cd /app && git pull && npm ci --production && pm2 restart app"

(Adjust the DEPLOY_KEY handling to your key format; never echo raw private keys in logs. Prefer webfactory/ssh-agent or OIDC to cloud deploy roles when you can retire SSH keys entirely.)

Docker: build, cache, and push

BuildKit with cache-to: type=gha and cache-from: type=gha makes repeated image builds much cheaper — especially in monorepos with stable base layers.

name: Docker

on:
  push:
    branches: [main]
    tags: ['v*']

jobs:
  docker:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: myorg/myapp
          tags: |
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=sha,prefix=sha-

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Deploy to AWS (ECR and ECS)

jobs:
  deploy-ecs:
    runs-on: ubuntu-latest
    needs: docker

    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push to ECR
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/myapp:$IMAGE_TAG .
          docker push $ECR_REGISTRY/myapp:$IMAGE_TAG

      - name: Deploy to ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: task-definition.json
          service: my-service
          cluster: my-cluster
          wait-for-service-stability: true

Long term, OIDC federation to AWS (instead of long‑lived AWS_ACCESS_KEY_ID secrets) is worth the one‑time setup — fewer static credentials, cleaner rotation, better alignment with how cloud IAM wants you to work.

Deploy to Vercel / Railway

# Vercel
- name: Deploy to Vercel
  uses: amondnet/vercel-action@v25
  with:
    vercel-token: ${{ secrets.VERCEL_TOKEN }}
    vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
    vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
    vercel-args: '--prod'     # Remove for preview deployments

# Or use Vercel CLI directly
- run: npx vercel --token ${{ secrets.VERCEL_TOKEN }} --prod

Useful cross‑cutting patterns

# Concurrency — cancel in-progress runs on same branch
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

# Upload build artifacts
- uses: actions/upload-artifact@v4
  with:
    name: build-output
    path: dist/
    retention-days: 5

# Download in another job
- uses: actions/download-artifact@v4
  with:
    name: build-output
    path: dist/

# Post a PR comment
- uses: peter-evans/create-or-update-comment@v4
  with:
    issue-number: ${{ github.event.pull_request.number }}
    body: |
      Build succeeded! Preview: https://preview-${{ github.sha }}.example.com

# Slack notification on failure
- name: Notify Slack on failure
  if: failure()
  uses: slackapi/slack-github-action@v1
  with:
    payload: |
      {"text": "Deploy failed on ${{ github.ref }} — ${{ github.run_url }}"}
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
  • [Docker Multi-stage Build Guide](/en/blog/docker-multistage-build-optimization/
  • [Kubernetes Practical Guide](/en/blog/kubernetes-practical-guide/
  • [Git Workflow Best Practices](/en/blog/git-workflow-best-practices-guide/

Frequently asked questions

When should I apply this in real projects?

A. As soon as you have automated tests and more than one person merging to main. Start with a single CI job, add matrix and caching as pain appears, and only then add full CD with environment gates.

What should I read first if I am new to CI?

A. Skim the official GitHub reference for workflow syntax and contexts, then return here for patterns. The [Docker multi-stage build guide](/en/blog/docker-multistage-build-optimization/ pairs well with the Docker + cache section above.

How do I go deeper on security and supply chain?

A. Read GitHub’s documentation on OIDC with cloud providers, pin Actions by commit SHA for high‑trust workflows, and review third‑party Actions the same way you would review a dependency in package.json.

More on this site

  • [GitHub Actions CI/CD Tutorial for Node.js | Test· Build](/en/blog/github-actions-ci-cd-tutorial/
  • [Docker Multi-Stage Build Guide | Optimization](/en/blog/docker-multistage-build-optimization/
  • [GitHub Actions Complete Guide | CI/CD· Workflows](/en/blog/github-actions-complete-guide/

Keywords for search: GitHub Actions, CI/CD, DevOps, Docker, Automation, Deployment