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-artifact → actions/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
pathsandpaths-ignoreso 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-daysand 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 }}
Related posts
- [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.
- [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