Tutorials / Shipping & infrastructure / Automate deploys with GitHub Actions
πŸ“ Written ● Beginner Updated 2026-05-13

Automate deploys with GitHub Actions

Run tests on every PR. Auto-deploy on every push to main. One YAML file in your repo. Free for public repos; 2,000 minutes/month free for private. Twenty minutes from "I push to GitHub" to "the live site is updated."

What you need

0
  • A GitHub account + repo β€” github.com β†—. Sign up free; create a repo β†— if you don't have one.
  • Code in the repo that has a test or build command β€” anything npm test / pytest / cargo test / ./gradlew test can run.
  • (Optional) Credentials for whatever you'll deploy to β€” Vercel token, AWS keys, SSH key, etc. We'll store these as Encrypted Secrets β†—.

Workflows live in .github/workflows/

1

Create the directory and a workflow file in your repo:

mkdir -p .github/workflows
touch .github/workflows/ci.yml

Every file ending in .yml here is a separate workflow. You can have many (CI, deploy, scheduled jobs, etc.).

The minimum CI: run tests on every PR

2

Paste this into .github/workflows/ci.yml (adjust for your language):

name: CI

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

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - run: npm ci
      - run: npm test

Commit, push. Go to your repo's Actions tab on github.com. You'll see the workflow run, with live logs.

For other languages, swap the setup step: Full marketplace: github.com/marketplace β†—.

Add a status badge to your README

3

In README.md:

![CI](https://github.com/<you>/<repo>/actions/workflows/ci.yml/badge.svg)

Green when tests pass, red when they fail. Visible on the repo home page; good signal for contributors.

Store secrets safely

4

Never commit API keys or tokens. Store them as Encrypted Secrets:

Repo page β†’ Settings β†’ Secrets and variables β†’ Actions β†’ New repository secret. Add e.g. VERCEL_TOKEN, AWS_ACCESS_KEY_ID, SSH_PRIVATE_KEY.

Use in a workflow as ${{ secrets.VERCEL_TOKEN }}:

      - run: vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }}

Secrets are decrypted only at job runtime; never logged. Secrets docs β†—.

Auto-deploy on push to main

5

Add a second job (or workflow) that only runs on main:

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - run: npm ci
      - run: npm run build

      # Pick ONE of these depending on where you deploy:

      # === Option A: 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"

      # === Option B: Self-hosted VPS via SSH ===
      # - uses: appleboy/ssh-action@v1
      #   with:
      #     host: ${{ secrets.SERVER_HOST }}
      #     username: deploy
      #     key: ${{ secrets.SSH_PRIVATE_KEY }}
      #     script: cd /var/www/myapp && ./deploy.sh

      # === Option C: AWS S3 + CloudFront ===
      # - 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
      # - run: aws s3 sync ./dist s3://my-bucket --delete

Uncomment whichever destination matches your setup. Push to main β†’ workflow runs β†’ site is updated. Watch in the Actions tab.

Mobile builds (iOS / Android)

6

Mobile builds need a Mac runner (iOS) or an Android SDK setup (Android). GitHub provides both.

iOS / TestFlight (combine with the iOS re-upload tutorial):

jobs:
  ios:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - run: xcodebuild -scheme MyApp -configuration Release archive \
                -archivePath build/MyApp.xcarchive
      - run: xcodebuild -exportArchive \
                -archivePath build/MyApp.xcarchive \
                -exportPath build \
                -exportOptionsPlist export.plist
      - run: xcrun altool --upload-app -f build/MyApp.ipa -t ios \
                --apiKey ${{ secrets.ASC_KEY_ID }} \
                --apiIssuer ${{ secrets.ASC_ISSUER_ID }}

Android / Play Store via fastlane β†—:

jobs:
  android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: "17" }
      - run: ./gradlew bundleRelease
      - uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
          packageName: com.example.myapp
          releaseFiles: app/build/outputs/bundle/release/app-release.aab
          track: internal

Cache dependencies for speed

7

The cache: "npm" line in setup-node above already caches ~/.npm between runs β€” typically cuts CI time in half. Equivalents exist for every language: cache: "pip", cache: "gradle", cache: "cargo".

For finer control, use actions/cache β†— directly. But the per-language helpers are usually enough.

Run only when needed

8

Save minutes by skipping workflows that don't need to run:

on:
  push:
    branches: [main]
    paths:
      - "src/**"          # only when source changes
      - "!docs/**"        # skip when only docs change
      - ".github/**"

Helps when you have multiple workflows (frontend deploy + backend deploy + docs build) but a given commit only touches one area.

Scheduled jobs (cron)

9

Run on a schedule, no push needed:

on:
  schedule:
    - cron: "0 6 * * *"   # daily at 06:00 UTC
  workflow_dispatch:       # also lets you trigger manually from the UI

Useful for: nightly backups, syncing data from an external API, cleaning up old records. crontab.guru β†— for cron syntax help.

Common failures and fixes

10
  • "Permission denied" deploying to your server β€” your SSH private key isn't in ~/.ssh/authorized_keys on the server, or it's the wrong format. Use ssh-keygen -t ed25519, paste the public key to server, the private key to GitHub Secrets.
  • "npm: command not found" β€” forgot the setup-node step. Every job starts on a clean runner.
  • Workflow runs but does nothing β€” check the on: trigger matches your branch name (main vs master).
  • Free minutes ran out (private repos) β€” usage at github.com/settings/billing β†—. Optimize with caching or upgrade.
Don't echo secrets. GitHub masks known secret values from logs, but if you pass a secret through a transformation (e.g., base64), the transformed version isn't masked. Avoid echo "$MY_SECRET" in scripts. Use ::add-mask:: if you must.

Official references

What's next