Caching for Speed
Without caching, npm ci downloads everything from npm on every run. With caching, subsequent runs skip the download entirely — cutting minutes off your pipeline.
steps:
- uses: actions/checkout@v4
# node setup with built-in npm cache
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm' # hashes package-lock.json automatically
# manual cache for build artifacts (e.g. Next.js .next/cache)
- name: Cache build artifacts
uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-
nextjs-${{ runner.os }}-
- run: npm ci
- run: npm run build
Docker Build and Push
jobs:
build-push:
name: Build & Push Docker Image
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write # for GitHub Container Registry
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=sha-
type=ref,event=branch
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha # GitHub Actions cache for layers
cache-to: type=gha,mode=max
build-args: |
BUILD_DATE=${{ github.event.head_commit.timestamp }}
GIT_SHA=${{ github.sha }}
Deploying to a VPS via SSH
deploy:
name: Deploy to Production
needs: [test-matrix, build-push]
runs-on: ubuntu-24.04
environment:
name: production
url: https://myapp.example.com
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: 22
script: |
set -e
cd /opt/myapp
# pull latest image
echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker pull ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
# snapshot last-good image before swapping
docker tag ghcr.io/${{ github.repository }}:latest ghcr.io/${{ github.repository }}:last-good 2>/dev/null || true
# atomic swap
docker compose up -d --no-deps app
docker image prune -f
Secrets and Environment Variables
# NEVER hardcode secrets. Use GitHub Secrets.
# Settings → Secrets and variables → Actions
jobs:
deploy:
steps:
- name: Run migrations
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
REDIS_URL: ${{ secrets.REDIS_URL }}
run: npm run db:migrate
# pass to Docker build-arg (does NOT embed in final image layers if used correctly)
- uses: docker/build-push-action@v6
with:
build-args: |
NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL }}
secrets: |
DATABASE_URL=${{ secrets.DATABASE_URL }}
Reusable Workflows
Reusable workflows let you define a workflow once and call it from multiple repositories or jobs. They are the DRY principle for CI/CD.
# .github/workflows/_reusable-test.yml
on:
workflow_call:
inputs:
node-version:
required: false
type: string
default: '22'
secrets:
DATABASE_URL:
required: true
jobs:
test:
runs-on: ubuntu-24.04
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
# .github/workflows/ci.yml — calling the reusable workflow
jobs:
test:
uses: ./.github/workflows/_reusable-test.yml
with:
node-version: '22'
secrets:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
Self-Hosted Runners
# run on your own hardware for faster builds or private network access
jobs:
deploy-internal:
runs-on: self-hosted # uses any runner with no labels
# OR target specific runners:
runs-on: [self-hosted, linux, x64, production]
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run deploy:internal
env:
INTERNAL_API_URL: ${{ secrets.INTERNAL_API_URL }}
Register a self-hosted runner at Settings → Actions → Runners → New self-hosted runner. The runner process runs on your machine and polls GitHub for jobs. Label runners to control which jobs run where.
A Complete Pipeline: Test, Build, Deploy, Verify
# .github/workflows/deploy.yml
name: Deploy Production
on:
push:
branches: [main]
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
test:
uses: ./.github/workflows/_reusable-test.yml
secrets: inherit
build:
needs: test
runs-on: ubuntu-24.04
outputs:
image-tag: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: type=sha,prefix=sha-
- uses: docker/build-push-action@v6
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build
runs-on: ubuntu-24.04
environment: production
steps:
- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: deploy
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /opt/myapp
IMAGE="ghcr.io/${{ github.repository }}:sha-${{ github.sha }}"
docker pull "$IMAGE"
IMAGE="$IMAGE" docker compose up -d --no-deps app
verify:
needs: deploy
runs-on: ubuntu-24.04
steps:
- name: Health check
run: |
for i in 1 2 3 4 5; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://myapp.example.com/healthz)
if [ "$STATUS" = "200" ]; then
echo "Health check passed"
exit 0
fi
echo "Attempt $i: got $STATUS, retrying in 10s..."
sleep 10
done
echo "Health check failed after 5 attempts"
exit 1
People Also Ask
How do I prevent GitHub Actions from running on draft pull requests?
Add a filter to your pull_request trigger: types: [opened, synchronize, reopened]. Draft PRs only trigger on opened and converted_to_draft — removing opened from the list does not help since drafts fire on it. The correct approach is to add a conditional: if: github.event.pull_request.draft == false on the job or workflow level.
What is the difference between secrets.GITHUB_TOKEN and a personal access token?
GITHUB_TOKEN is automatically provisioned by GitHub Actions for every run — it is scoped to the current repository and expires when the run ends. It is sufficient for pushing Docker images to GHCR, creating releases, and commenting on PRs. A PAT (personal access token) or fine-grained token is needed when you need to access other repositories, create webhooks, or perform actions that exceed the default token’s permissions. Always prefer GITHUB_TOKEN where possible — it is the principle of least privilege.
How do I speed up npm install in GitHub Actions?
Use actions/setup-node with cache: 'npm' — this caches the npm global cache keyed on package-lock.json. Also use npm ci (not npm install) in CI — it skips the dependency resolution step, uses the lockfile directly, and is 2–3x faster. For monorepos, cache the individual package directories with actions/cache using a hash of all package-lock.json files as the key.
Comments · 0
Beta: comments are stored locally on your device and not visible to other readers.
No comments yet. Be the first to share your thoughts.