GitHub Actions
The Definitive Guide
From manual FTP uploads to fully automated CI/CD pipelines. Master workflows, jobs, runners, and real-world deployment strategies with Docker & Kubernetes.
What is GitHub Actions?
A built-in CI/CD and automation platform native to GitHub that lets you build, test, and deploy code directly from your repository using simple YAML configuration.
✕ The Old Way (Manual Deployment)
- Developer finishes code on their local machine
- Runs tests manually (or forgets entirely)
- Connects to the server via FTP or SSH
- Uploads files one by one, hoping nothing breaks
- Restarts services manually, crossing fingers
- Discovers at 2 AM that production is down
✓ With GitHub Actions
- Developer pushes code to GitHub
- Automated tests run instantly
- Code is built and packaged automatically
- Deployed to staging, then to production
- Team is notified via Slack on success or failure
- Full audit trail of every deployment in Git history
Core Concepts
Understanding the building blocks that make up every GitHub Actions workflow.
Workflow
A configurable automated process defined in a YAML file inside .github/workflows/. A repository can have multiple workflows — one for CI, one for deployment, one for nightly tests, etc.
Job
A set of steps that execute on the same runner. Jobs run in parallel by default but can be configured to run sequentially using the needs keyword for dependency chains.
Step
An individual task within a job. Each step is either a shell command (using run) or a reference to a reusable action (using uses). Steps execute sequentially within their job.
Runner
A server that runs your workflows. GitHub provides hosted runners (Ubuntu, Windows, macOS) or you can register your own self-hosted runners for specialised hardware or network access.
Action
A reusable unit of code that performs a specific task. Can be sourced from the GitHub Marketplace, another repository, or defined locally. Think of it as a plugin for your pipeline.
Event
A trigger that starts a workflow. Common events include push, pull_request, schedule (cron), workflow_dispatch (manual), and release.
How They Fit Together
Anatomy of a Workflow File
Every workflow lives in .github/workflows/ and follows a clear, hierarchical YAML structure.
# ── Minimal Workflow Structure ── name: My First Workflow # Display name on: # Trigger events push: branches: [ main ] pull_request: branches: [ main ] env: # Global environment variables NODE_ENV: production jobs: # Define jobs build: # Job ID runs-on: ubuntu-latest # Runner steps: - name: Checkout code uses: actions/checkout@v4 # Reusable action - name: Run tests run: npm test # Shell command
Event Triggers Deep Dive
GitHub Actions supports dozens of events. Here are the most commonly used ones and how to configure them.
| Event | Description | Example Use Case |
|---|---|---|
push | Fires when code is pushed to a branch | Run CI on every commit to main |
pull_request | Fires on PR open, sync, or reopen | Run tests before merging to main |
schedule | Cron-based schedule (UTC) | Nightly builds, weekly security scans |
workflow_dispatch | Manual trigger via GitHub UI / API | On-demand deployments, hotfixes |
release | Fires when a release is published | Publish packages to npm / NuGet |
workflow_run | Fires after another workflow completes | Deploy after CI passes |
on: # ── Branch & path filtering ── push: branches: [ main, release/* ] paths: - 'src/**' # Only trigger when source code changes - '!src/**/*.md' # Ignore markdown changes tags: - 'v*' # Trigger on version tags # ── Cron schedule (daily at midnight UTC) ── schedule: - cron: '0 0 * * *' # ── Manual trigger with input parameters ── workflow_dispatch: inputs: environment: description: 'Target environment' required: true default: 'staging' type: choice options: - staging - production
Scenario 1: Manual to Automated Web Deployment
A company migrating from manual FTP/SSH uploads to a fully automated CI/CD pipeline deploying to live servers.
Pipeline Architecture
# ═══════════════════════════════════════════════════════════════════ # ACME WEB SOLUTIONS — Automated Deployment to Live Server # Replaces manual SSH + FTP workflow with full CI/CD pipeline # ═══════════════════════════════════════════════════════════════════ name: 🚀 Build & Deploy to Production on: push: branches: [ main ] # Only deploy from main branch workflow_dispatch: # Allow manual trigger for hotfixes inputs: skip_tests: description: 'Skip tests (emergency hotfix only)' type: boolean default: false env: NODE_VERSION: '20' APP_NAME: 'acme-webapp' DEPLOY_PATH: '/var/www/acme-webapp' jobs: # ── JOB 1: Lint & Test ── test: name: 🧪 Lint & Test runs-on: ubuntu-latest if: ${{ !inputs.skip_tests }} # Skip for emergency hotfixes steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' # Cache node_modules for speed - name: Install dependencies run: npm ci # Clean install (faster, deterministic) - name: Run ESLint run: npm run lint - name: Run unit tests run: npm test -- --coverage - name: Upload coverage report uses: actions/upload-artifact@v4 with: name: coverage-report path: coverage/ # ── JOB 2: Build ── build: name: 🔨 Build Application runs-on: ubuntu-latest needs: test # Wait for tests to pass if: ${{ always() && (needs.test.result == 'success' || needs.test.result == 'skipped') }} steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Install dependencies run: npm ci - name: Build production bundle run: npm run build env: REACT_APP_API_URL: ${{ secrets.PRODUCTION_API_URL }} - name: Upload build artifact uses: actions/upload-artifact@v4 with: name: production-build path: build/ retention-days: 7 # ── JOB 3: Deploy to Live Server ── deploy: name: 🚀 Deploy to Production runs-on: ubuntu-latest needs: build # Wait for build to succeed environment: name: production # GitHub Environment (for protection rules) url: https://acme-web.com steps: - name: Download build artifact uses: actions/download-artifact@v4 with: name: production-build path: build/ - name: Setup SSH key run: | mkdir -p ~/.ssh echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key ssh-keyscan -H ${{ secrets.SERVER_HOST }} >> ~/.ssh/known_hosts - name: Deploy via rsync (zero-downtime) run: | # Sync build files to server (only changed files) rsync -avz --delete \ -e "ssh -i ~/.ssh/deploy_key" \ build/ \ ${{ secrets.SSH_USER }}@${{ secrets.SERVER_HOST }}:${{ env.DEPLOY_PATH }}/ - name: Restart application server run: | ssh -i ~/.ssh/deploy_key \ ${{ secrets.SSH_USER }}@${{ secrets.SERVER_HOST }} \ "cd ${{ env.DEPLOY_PATH }} && \ npm ci --production && \ pm2 reload ${{ env.APP_NAME }} --update-env" - name: Verify deployment (health check) run: | sleep 10 STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://acme-web.com/api/health) if [ "$STATUS" != "200" ]; then echo "❌ Health check failed with status $STATUS" exit 1 fi echo "✅ Deployment verified — server is healthy" - name: Notify team on Slack if: always() # Send notification even on failure uses: slackapi/slack-github-action@v1.27.0 with: payload: | { "text": "${{ job.status == 'success' && '✅' || '❌' }} Deployment to production ${{ job.status }}", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "*${{ job.status == 'success' && '✅ Deployed' || '❌ Failed' }}* — `${{ github.sha }}` by ${{ github.actor }}" } } ] } env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SSH_PRIVATE_KEY, SSH_USER, SERVER_HOST, PRODUCTION_API_URL, and SLACK_WEBHOOK_URL.Scenario 2: Docker & Kubernetes Deployment
Containerising the application and deploying to a Kubernetes cluster for horizontal scaling, rolling updates, and self-healing infrastructure.
Container Pipeline Architecture
Step A — The Dockerfile
# ── Stage 1: Build ── FROM node:20-alpine AS builder WORKDIR /app # Copy package files first (leverages Docker layer caching) COPY package*.json ./ RUN npm ci # Copy source code and build COPY . . RUN npm run build # ── Stage 2: Production ── FROM node:20-alpine AS production WORKDIR /app # Only copy what we need for production COPY --from=builder /app/build ./build COPY --from=builder /app/package*.json ./ RUN npm ci --omit=dev # Security: run as non-root user RUN addgroup -g 1001 appgroup && adduser -u 1001 -G appgroup -s /bin/sh -D appuser USER appuser EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 CMD ["node", "build/server.js"]
Step B — Kubernetes Manifests
apiVersion: apps/v1 kind: Deployment metadata: name: acme-webapp labels: app: acme-webapp spec: replicas: 3 # Run 3 pods for high availability selector: matchLabels: app: acme-webapp strategy: type: RollingUpdate # Zero-downtime rolling deployment rollingUpdate: maxSurge: 1 # Add 1 new pod at a time maxUnavailable: 0 # Never reduce below desired count template: metadata: labels: app: acme-webapp spec: containers: - name: webapp image: ghcr.io/acme/webapp:IMAGE_TAG # Replaced by CI/CD ports: - containerPort: 3000 resources: requests: cpu: "100m" memory: "128Mi" limits: cpu: "500m" memory: "512Mi" livenessProbe: # K8s auto-restarts unhealthy pods httpGet: path: /api/health port: 3000 initialDelaySeconds: 15 periodSeconds: 20 readinessProbe: # Only route traffic when ready httpGet: path: /api/health port: 3000 initialDelaySeconds: 5 periodSeconds: 10 env: - name: NODE_ENV value: "production" - name: DATABASE_URL valueFrom: secretKeyRef: name: acme-secrets key: database-url --- apiVersion: v1 kind: Service metadata: name: acme-webapp-service spec: type: LoadBalancer selector: app: acme-webapp ports: - port: 80 targetPort: 3000 protocol: TCP
Step C — The GitHub Actions Workflow
# ═══════════════════════════════════════════════════════════════════ # ACME — Docker Build & Kubernetes Deployment Pipeline # Builds container image, pushes to GHCR, deploys to K8s cluster # ═══════════════════════════════════════════════════════════════════ name: 🐳 Docker Build & K8s Deploy on: push: tags: - 'v*.*.*' # Trigger on semantic version tags env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} K8S_NAMESPACE: production permissions: contents: read packages: write # Needed to push to GHCR jobs: # ── JOB 1: Run Tests ── test: name: 🧪 Test Suite runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - run: npm ci - run: npm run lint - run: npm test # ── JOB 2: Build & Push Docker Image ── build-and-push: name: 🐳 Build & Push Image runs-on: ubuntu-latest needs: test outputs: image-tag: ${{ steps.meta.outputs.tags }} image-digest: ${{ steps.build.outputs.digest }} steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha,prefix=sha- - name: Build and push Docker image id: build uses: docker/build-push-action@v5 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha # GitHub Actions layer cache cache-to: type=gha,mode=max platforms: linux/amd64,linux/arm64 # Multi-arch # ── JOB 3: Deploy to Kubernetes ── deploy: name: ☸️ Deploy to Kubernetes runs-on: ubuntu-latest needs: build-and-push environment: name: production url: https://acme-web.com steps: - name: Checkout repository uses: actions/checkout@v4 - name: Configure kubectl uses: azure/setup-kubectl@v4 - name: Set Kubernetes context uses: azure/k8s-set-context@v4 with: method: kubeconfig kubeconfig: ${{ secrets.KUBE_CONFIG }} - name: Update image tag in manifest run: | cd k8s TAG="${{ github.ref_name }}" # e.g., v1.2.3 IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${TAG}" sed -i "s|IMAGE_TAG|${TAG}|g" deployment.yml echo "🐳 Deploying image: ${IMAGE}" - name: Apply Kubernetes manifests run: | kubectl apply -f k8s/deployment.yml -n ${{ env.K8S_NAMESPACE }} - name: Wait for rollout to complete run: | kubectl rollout status deployment/acme-webapp \ -n ${{ env.K8S_NAMESPACE }} \ --timeout=300s - name: Verify pods are running run: | kubectl get pods -n ${{ env.K8S_NAMESPACE }} -l app=acme-webapp echo "" echo "📊 Deployment details:" kubectl describe deployment acme-webapp -n ${{ env.K8S_NAMESPACE }} | head -30 - name: Rollback on failure if: failure() run: | echo "❌ Deployment failed — initiating rollback" kubectl rollout undo deployment/acme-webapp -n ${{ env.K8S_NAMESPACE }} kubectl rollout status deployment/acme-webapp -n ${{ env.K8S_NAMESPACE }}
kubectl rollout status fails. Combined with Kubernetes' maxUnavailable: 0 strategy, this ensures zero-downtime even when a deployment goes wrong.Secrets & Security
Never hardcode credentials. GitHub provides encrypted secrets at the repository and organisation level.
Repository Secrets
Available to workflows in a single repository. Set them at Settings → Secrets and variables → Actions. Referenced as ${{ secrets.NAME }}.
Environment Secrets
Scoped to a specific environment (e.g., staging, production). Support protection rules like required reviewers and wait timers before deployment proceeds.
Organisation Secrets
Shared across repositories within an organisation. Configured at the org level and can be restricted to specific repos or made available to all.
GITHUB_TOKEN
Automatically generated for each workflow run. Provides scoped access to the repository's API, packages, and more. No setup required — just use ${{ secrets.GITHUB_TOKEN }}.
permissions key to follow the principle of least privilege. Pin actions to full commit SHAs instead of tags for supply-chain security (e.g., actions/checkout@a5ac7e... instead of @v4).Matrix Builds
Run the same job across multiple configurations — Node versions, operating systems, or database engines — in parallel.
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-version: [ 18, 20, 22 ] exclude: - os: macos-latest # Skip Node 18 on macOS node-version: 18 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - run: npm ci - run: npm test
Marketplace Actions
The GitHub Marketplace offers thousands of pre-built actions. Here are the most essential ones used across the industry.
| Action | Purpose | Usage |
|---|---|---|
actions/checkout@v4 | Clone your repository into the runner | Present in virtually every workflow |
actions/setup-node@v4 | Install and cache Node.js | JavaScript/TypeScript projects |
actions/setup-dotnet@v4 | Install and cache .NET SDK | .NET / C# projects |
actions/cache@v4 | Cache dependencies across runs | Speed up npm, NuGet, pip installs |
docker/build-push-action@v5 | Build and push Docker images | Container-based deployments |
azure/k8s-set-context@v4 | Connect to Kubernetes clusters | K8s deployments (AKS/EKS/GKE) |
actions/upload-artifact@v4 | Store build outputs between jobs | Pass artifacts across job boundaries |
slackapi/slack-github-action | Send notifications to Slack | Deployment alerts and status updates |
Best Practices
Production-proven patterns to keep your pipelines fast, secure, and maintainable.
Cache Aggressively
Use actions/cache or built-in caching in setup actions. Caching node_modules or .nuget/packages can cut install times from minutes to seconds.
Pin Action Versions
Use full commit SHAs instead of version tags for third-party actions. This prevents supply-chain attacks where a compromised tag could inject malicious code into your pipeline.
Use Reusable Workflows
Extract common CI/CD patterns into shared workflow files using workflow_call. Centralise your standards and eliminate copy-paste across repositories.
Least Privilege Permissions
Always declare explicit permissions at the workflow or job level. Default token permissions are often too broad. Lock them to only what each job actually needs.
Fail Fast, Fix Fast
Put the cheapest, fastest checks (linting, type checking) first. If code style fails in 10 seconds, there's no point running a 5-minute integration test suite.
Environments & Approvals
Use GitHub Environments with protection rules to gate production deployments. Require manual approval from senior engineers before any code reaches live servers.
Where & How to Practice — Free of Cost
You don't need a company infrastructure or a credit card to learn CI/CD hands-on. Here's a complete free-tier stack to practice everything in this guide.
Step-by-Step: Your Free Practice Lab
1 GitHub — Your CI/CD Engine (Free)
Create a free GitHub account and a public repository. This gives you unlimited Actions minutes. Push any project — even a simple Node.js "Hello World" — and start writing .github/workflows/ files immediately.
# Create your practice repo mkdir github-actions-lab && cd github-actions-lab git init npm init -y # Add a simple test script to package.json echo '{ "scripts": { "test": "echo Running tests... && exit 0" } }' > package.json # Create your first workflow mkdir -p .github/workflows touch .github/workflows/ci.yml # Push to GitHub and watch the Actions tab light up! git add -A && git commit -m "My first CI pipeline" git remote add origin https://github.com/YOUR_USER/github-actions-lab.git git push -u origin main
| Plan | Minutes/Month | Storage | Cost |
|---|---|---|---|
| Public repos | Unlimited | 500 MB artifacts | Free |
| Private repos (Free tier) | 2,000 (Linux) | 500 MB artifacts | Free |
2 Docker Desktop — Build & Test Containers Locally (Free)
Docker Desktop is free for personal use and small businesses. Install it, write your Dockerfile, and test your container builds locally before pushing them through GitHub Actions.
# Build your image locally first — iterate fast docker build -t my-app:dev . # Run and test it docker run -p 3000:3000 my-app:dev # Verify the health endpoint curl http://localhost:3000/api/health # Once it works locally, push to GHCR via Actions (free for public repos) # GitHub Container Registry (ghcr.io) is free for public packages
act (github.com/nektos/act) to run GitHub Actions workflows locally using Docker. It simulates the GitHub runner on your machine — perfect for debugging workflows without pushing commits.3 Kubernetes — Free Local & Cloud Options
You don't need a paid cloud cluster. Several tools give you a fully functional K8s cluster on your laptop or for free in the cloud.
Minikube
Runs a single-node Kubernetes cluster inside a VM or Docker container on your machine. The most popular local option.
minikube start --driver=docker kubectl get nodes # You now have a fully working K8s cluster! kubectl apply -f k8s/deployment.yml minikube service acme-webapp-service
Kind (K8s in Docker)
Creates K8s clusters using Docker containers as "nodes." Extremely lightweight, starts in seconds, perfect for CI/CD testing.
kind create cluster --name practice kubectl cluster-info --context kind-practice # Multi-node cluster? Easy: kind create cluster --config kind-config.yml
K3s / K3d
A lightweight, certified Kubernetes distribution by Rancher. K3d wraps K3s in Docker for easy local clusters. Uses minimal RAM.
k3d cluster create mycluster kubectl get pods --all-namespaces # Full K8s API — just lighter weight
Docker Desktop (Built-in)
Docker Desktop ships with a built-in single-node Kubernetes cluster. Just enable it in Settings → Kubernetes → Enable Kubernetes.
# No install needed if Docker Desktop is running # Settings → Kubernetes → ✅ Enable kubectl config use-context docker-desktop kubectl get nodes
4 Free Cloud Platforms for Deployment Targets
To practise the deploy step of your pipelines (not just build and test), you need somewhere to deploy to. These platforms offer generous free tiers:
| Platform | Free Tier | Best For Practising |
|---|---|---|
| Render | Free web services, static sites, PostgreSQL (90 days) | Deploying Node.js/Python apps via GitHub push |
| Railway | $5 free credit/month, Docker deployments | Container deployments, databases, full-stack apps |
| Fly.io | 3 shared VMs, 3 GB storage free | Docker container deployments globally |
| Vercel | Unlimited static/serverless deploys (hobby) | Next.js, React, static site deployments |
| GitHub Pages | Free static site hosting from any repo | Static HTML/CSS/JS deployments via Actions |
| Oracle Cloud | Always-free ARM VMs (4 cores, 24 GB RAM!) | Full Linux server — SSH deploy, Docker, K3s cluster |
| Azure (Student/Free) | $200 credit (30 days) + 12 months free services | AKS, App Service, Azure Container Instances |
| Google Cloud | $300 credit (90 days) + always-free tier | GKE Autopilot, Cloud Run, Compute Engine |
| AWS | 12-month free tier + always-free services | EC2, ECS, EKS (free control plane), S3, Lambda |
5 Interactive Learning Playgrounds
Hands-on sandboxes where you can learn without installing anything locally:
Killercoda
Free browser-based sandboxes for Kubernetes, Docker, Linux, and CI/CD. Guided scenarios with real terminals — no setup needed. Excellent for K8s practice.
killercoda.com
Play with Docker
Free 4-hour Docker playground in the browser. Spin up multiple Docker nodes and practice container builds, networking, and multi-host setups instantly.
labs.play-with-docker.com
Play with Kubernetes
Free browser-based K8s cluster for 4 hours. Practise deployments, services, and rolling updates without any cloud account.
labs.play-with-k8s.com
GitHub Skills
Official GitHub-authored courses that run inside real repositories. Includes a dedicated "GitHub Actions" learning path with hands-on exercises.
skills.github.com
Recommended Practice Roadmap
npm test on push. Experiment with different triggers, matrix builds, and caching. Deploy a static site to GitHub Pages via Actions.act to run workflows locally. Deploy containers to Render or Railway free tier.