GitHub Actions CI/CD in 2026: Build, Test, and Deploy a Full-Stack App with Reusable Workflows

GitHub Actions has become the go-to CI/CD platform for developers worldwide, and in 2026, its ecosystem of reusable workflows, improved caching, and native container support make it more powerful than ever. In this guide, we’ll build a complete CI/CD pipeline that builds, tests, and deploys a full-stack application using reusable workflows — a pattern that keeps your pipelines DRY and maintainable across multiple repositories.

Why Reusable Workflows Matter

If you’ve ever copied the same workflow YAML across ten repositories, you know the pain. One security patch or config change means updating every single repo. Reusable workflows solve this by letting you define a workflow once and call it from any repository, passing inputs and secrets as needed.

Project Structure

We’ll work with a typical full-stack app:

my-app/
├── frontend/          # React/Vite app
├── backend/           # Node.js API
├── docker-compose.yml
└── .github/
    └── workflows/
        ├── ci.yml           # Main pipeline
        ├── reusable-test.yml # Reusable test workflow
        └── reusable-deploy.yml # Reusable deploy workflow

Step 1: Create a Reusable Test Workflow

Let’s start with a reusable workflow that can test any Node.js project. This lives in reusable-test.yml:

name: Reusable Test

on:
  workflow_call:
    inputs:
      working-directory:
        required: true
        type: string
      node-version:
        required: false
        type: string
        default: '22'
    secrets:
      NPM_TOKEN:
        required: false

jobs:
  test:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ${{ inputs.working-directory }}
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'npm'
          cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json

      - name: Install dependencies
        run: npm ci
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

      - name: Lint
        run: npm run lint --if-present

      - name: Run tests
        run: npm test -- --coverage

      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage-${{ inputs.working-directory }}
          path: ${{ inputs.working-directory }}/coverage/
          retention-days: 7

The key here is workflow_call — this trigger makes the workflow callable from other workflows. We accept inputs for configuration and secrets for sensitive data.

Step 2: Create a Reusable Deploy Workflow

Next, a reusable deployment workflow using Docker and SSH:

name: Reusable Deploy

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      image-name:
        required: true
        type: string
      dockerfile-path:
        required: true
        type: string
    secrets:
      DOCKER_USERNAME:
        required: true
      DOCKER_PASSWORD:
        required: true
      SSH_PRIVATE_KEY:
        required: true
      DEPLOY_HOST:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4

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

      - name: Build and push image
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ${{ inputs.dockerfile-path }}
          push: true
          tags: |
            ${{ secrets.DOCKER_USERNAME }}/${{ inputs.image-name }}:latest
            ${{ secrets.DOCKER_USERNAME }}/${{ inputs.image-name }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: deploy
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            docker pull ${{ secrets.DOCKER_USERNAME }}/${{ inputs.image-name }}:latest
            cd /opt/apps/${{ inputs.image-name }}
            docker compose up -d --force-recreate

Notice the cache-from and cache-to using GitHub Actions cache (type=gha). This dramatically speeds up Docker builds by caching layers between runs.

Step 3: The Main CI/CD Pipeline

Now we wire everything together in ci.yml:

name: CI/CD Pipeline

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

jobs:
  test-frontend:
    uses: ./.github/workflows/reusable-test.yml
    with:
      working-directory: frontend
      node-version: '22'

  test-backend:
    uses: ./.github/workflows/reusable-test.yml
    with:
      working-directory: backend
      node-version: '22'
    secrets:
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

  deploy-staging:
    needs: [test-frontend, test-backend]
    if: github.ref == 'refs/heads/develop'
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: staging
      image-name: my-app-api
      dockerfile-path: backend/Dockerfile
    secrets:
      DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
      DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
      SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
      DEPLOY_HOST: ${{ secrets.STAGING_HOST }}

  deploy-production:
    needs: [test-frontend, test-backend]
    if: github.ref == 'refs/heads/main'
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: production
      image-name: my-app-api
      dockerfile-path: backend/Dockerfile
    secrets:
      DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
      DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
      SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
      DEPLOY_HOST: ${{ secrets.PRODUCTION_HOST }}

This is clean, readable, and powerful. The frontend and backend tests run in parallel, and deployment only proceeds if both pass. Pushing to develop deploys to staging; pushing to main deploys to production.

Pro Tips for GitHub Actions in 2026

  • Use concurrency groups to cancel outdated runs: concurrency: { group: ${{ github.workflow }}-${{ github.ref }}, cancel-in-progress: true }
  • Pin action versions to commit SHAs instead of tags for supply-chain security: uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29
  • Use workflow_dispatch alongside workflow_call so reusable workflows can also be triggered manually for debugging
  • Leverage GitHub Environments with protection rules — require approvals for production deployments
  • Cache aggressively — npm, Docker layers, and build artifacts. The actions/cache v4 supports cross-branch cache restoration

Sharing Reusable Workflows Across Repos

For organization-wide workflows, create a dedicated .github repository and reference workflows from any repo:

jobs:
  test:
    uses: my-org/.github/.github/workflows/reusable-test.yml@main
    with:
      working-directory: src
    secrets: inherit  # Pass all secrets automatically

The secrets: inherit keyword (available since 2023) eliminates the need to explicitly pass every secret — it forwards all organization and repository secrets to the called workflow.

Wrapping Up

Reusable workflows transform GitHub Actions from simple automation scripts into a proper CI/CD platform. By extracting common patterns into callable workflows, you reduce duplication, enforce consistency, and make pipeline changes a single-repo operation instead of a multi-repo headache. Start by identifying the workflows you’ve copied most often — those are your first candidates for refactoring into reusable templates.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

Privacy Policy · Contact · Sitemap

© 7Tech – Programming and Tech Tutorials