A complete, reusable pattern for wiring a code-quality gate (SonarQube) to Docker
build, image scanning, and SSH-based deployment across development, staging, and
production using a single reusable workflow and GitHub Environments for secrets
and approval gates.

This document explains not just what to copy, but why each piece is shaped the
way it is, including the failure modes that quietly break this kind of pipeline.

1. The problem this solves

A common goal: when code lands on a branch and passes the quality gate, automatically
build a Docker image, scan it for vulnerabilities, and deploy it to the server that
matches that branch develop to dev, staging to staging, main to production, with production requiring a human to approve the release.

The naive approach is one deploy file per environment, each triggered independently.
That works but rots fast: three files drift apart, a fix in one is forgotten in the
others, and secrets get duplicated. The pattern below uses one reusable deploy
workflow parameterized by environment, called from the quality-gate workflow, with
per-environment secrets and protection rules living in GitHub Environments.

2. The workflow_run trap (why the obvious approach fails silently)

The instinctive way to chain "run B after A finishes" is the workflow_run trigger:

on:
  workflow_run:
    workflows: ["CodeQuality Checks"]
    types: [completed]
    branches: [develop]

Enter fullscreen mode Exit fullscreen mode

This looks correct and frequently does nothing at all. Two rules cause most of the
silent failures:

Rule 1: workflow_run is armed only from the default branch. GitHub reads a
workflow_run trigger definition only from the copy of the file on the repository's
default branch. If your default branch is, say, main (or something unexpected like a
codex/* branch) and the deploy file exists only on develop, the trigger is never
registered. The upstream workflow can pass a thousand times; nothing listens. The
branches: filter does not change this — the file must physically exist on the default
branch to be active, even though the filter then restricts it to acting on develop.

A quick way to discover your real default branch is the "This branch is N commits ahead
of X" banner on the repo's Code tab — X is the default branch GitHub compares against.

Rule 2: the branches: filter matches the upstream run's head branch. It does
not mean "the target branch." If the upstream (quality) workflow runs on
pull_request, its head branch is the PR's source branch (e.g. feature/x), not
develop, so branches: [develop] never matches. It only lines up when the upstream
runs on push to develop.

Because of these two traps and because relying on the default branch is undesirable
when your default branch is not the branch you deploy from — this document avoids
workflow_run entirely and uses a reusable workflow instead.

3. Why a reusable workflow (workflow_call) is the right primitive

A workflow invoked with workflow_call is resolved from the caller's ref, not the
default branch. If the quality workflow runs on develop and calls the deploy workflow
with uses: ./.github/workflows/deploy.yml, GitHub loads the deploy file from
develop
. No default-branch dependency, no head-branch filter guessing. The two
workflows also appear as a single run graph with a real dependency edge, so a failed
build visibly blocks the deploy.

workflow_dispatch (manual/API trigger) is not an escape hatch here: dispatching via
the API has the same default-branch requirement as workflow_run. Reusable
workflow_call is the only model fully independent of the default branch.

4. Architecture overview

push to develop / staging / main
        │
        ▼
┌─────────────────────────┐
│  Quality workflow        │   runs on: push [develop, staging, main]
│  (SonarQube scan + gate) │
└───────────┬─────────────┘
            │ success()
            ▼
┌─────────────────────────┐
│  resolve-env job         │   maps branch → environment / tag / path
└───────────┬─────────────┘
            │ outputs
            ▼
┌─────────────────────────────────────────────┐
│  deploy.yml (reusable, workflow_call)         │
│                                               │
│  dependency-check → build-and-push →          │
│  image-scan → deploy (Environment-gated)      │
└───────────────────────────────────────────────┘
            │
            ▼
   dev / staging / production server (SSH + docker compose)

Enter fullscreen mode Exit fullscreen mode

Branch-to-environment mapping:

Branch Environment Image tag Deploy path Approval
develop dev dev /home/apps/pmis-web-dev none
staging staging staging /home/apps/pmis-web-staging none
main production prod /home/apps/pmis-web-prod required

5. Prerequisites: GitHub Environments

Before the workflows will work, create three Environments under
Settings → Environments: dev, staging, and production.

Environments do two jobs here. First, they scope secrets: each environment holds its
own copy of the deploy secrets pointing at that environment's server. Second, they
enforce protection rules: adding required reviewers to production makes the deploy
job pause and wait for a human to approve before it runs.

Inside each environment, define these secrets (same key names, different values per
environment):

Secret Meaning
SERVER_IP Target host for that environment
SERVER_USER SSH user
SERVER_SSH_KEY Private key for that user
GHCR_TOKEN Token with read:packages for pulling on server
GHCR_USERNAME GHCR username matching the token
NEXT_PUBLIC_API_URL Public API URL baked into the build for that env

Repository-level (or organization-level) secrets, shared across environments:

Secret Meaning
SONAR_TOKEN SonarQube auth token
SONAR_HOST_URL SonarQube server URL

GITHUB_TOKEN is provided automatically; no need to create it.

Note: the key names here are env-neutral (SERVER_IP rather than DEV_SERVER_IP).
Because each environment supplies its own value, there is no reason to prefix them
with DEV_. Pick one convention and use it consistently in both the workflow files
and every environment.

6. The quality-gate workflow (the caller)

This runs on every push to the three deployable branches, runs the SonarQube scan and
quality gate, then only if the gate passed and the push was to a known branch, resolves the target environment and calls the reusable deploy workflow.

name: CodeQuality Checks

on:
  push:
    branches:
      - develop
      - staging
      - main

jobs:
  # ─────────────────────────────────────────────────────
  # 1. SonarQube scan + quality gate
  # ─────────────────────────────────────────────────────
  code-quality:
    name: Code Quality
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v6
        with:
          fetch-depth: 0            # full history so Sonar can attribute blame

      - name: SonarQube Scan
        uses: sonarsource/sonarqube-scan-action@master
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
          SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}

      - name: SonarQube Quality Gate
        uses: sonarsource/sonarqube-quality-gate-action@master
        timeout-minutes: 5
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

  # ─────────────────────────────────────────────────────
  # 2. Map the pushed branch to an environment
  # ─────────────────────────────────────────────────────
  resolve-env:
    name: Resolve Target Environment
    needs: [code-quality]
    if: >-
      ${{ success() &&
          contains(fromJSON('["refs/heads/develop","refs/heads/staging","refs/heads/main"]'),
                   github.ref) }}
    runs-on: ubuntu-latest
    outputs:
      environment: ${{ steps.map.outputs.environment }}
      image_tag:   ${{ steps.map.outputs.image_tag }}
      deploy_path: ${{ steps.map.outputs.deploy_path }}
    steps:
      - name: Map branch to environment
        id: map
        run: |
          case "${{ github.ref }}" in
            refs/heads/develop)
              echo "environment=dev"                       >> $GITHUB_OUTPUT
              echo "image_tag=dev"                          >> $GITHUB_OUTPUT
              echo "deploy_path=/home/apps/pmis-web-dev"     >> $GITHUB_OUTPUT
              ;;
            refs/heads/staging)
              echo "environment=staging"                   >> $GITHUB_OUTPUT
              echo "image_tag=staging"                      >> $GITHUB_OUTPUT
              echo "deploy_path=/home/apps/pmis-web-staging" >> $GITHUB_OUTPUT
              ;;
            refs/heads/main)
              echo "environment=production"                >> $GITHUB_OUTPUT
              echo "image_tag=prod"                         >> $GITHUB_OUTPUT
              echo "deploy_path=/home/apps/pmis-web-prod"    >> $GITHUB_OUTPUT
              ;;
          esac

  # ─────────────────────────────────────────────────────
  # 3. Call the reusable deploy workflow
  # ─────────────────────────────────────────────────────
  deploy:
    name: Deploy
    needs: resolve-env
    permissions:
      contents: read
      packages: write
    uses: ./.github/workflows/deploy.yml
    with:
      environment: ${{ needs.resolve-env.outputs.environment }}
      image_tag:   ${{ needs.resolve-env.outputs.image_tag }}
      deploy_path: ${{ needs.resolve-env.outputs.deploy_path }}
      head_sha:    ${{ github.sha }}
    secrets: inherit

Enter fullscreen mode Exit fullscreen mode

Notes on the caller:

success() in the resolve-env guard already means every job in needs passed, so a
separate "conclusion == success" check is redundant. The contains(fromJSON(...))
check ensures the pipeline only fires on the three intended branches and skips cleanly
on anything else. secrets: inherit forwards all repository and the resolved
environment's secrets into the called workflow — without it the reusable workflow sees
no secrets at all.

Passing head_sha: ${{ github.sha }} and having the reusable workflow check that exact
commit out guarantees you build precisely what Sonar validated, not whatever happens to
be at the branch tip when the deploy job starts.

7. The reusable deploy workflow (the engine)

Four jobs run in sequence: audit dependencies, build and push the image, scan the
pushed image, then deploy over SSH. The deploy job is bound to the resolved
Environment, which is what activates per-environment secrets and the production
approval gate.

name: Deploy PMIS Web

on:
  workflow_call:
    inputs:
      environment:
        description: "Target environment (dev | staging | production)"
        required: true
        type: string
      image_tag:
        description: "Image tag to build, push, and deploy"
        required: true
        type: string
      deploy_path:
        description: "Absolute path to the compose project on the server"
        required: true
        type: string
      head_sha:
        description: "Commit SHA to build and deploy"
        required: true
        type: string
      audit_level:
        description: "npm audit failure threshold"
        required: false
        type: string
        default: high

env:
  REGISTRY: ghcr.io

concurrency:
  group: deploy-pmis-web-${{ inputs.environment }}
  cancel-in-progress: false

jobs:
  # ─────────────────────────────────────────────────────
  # 1. Dependency vulnerability check
  # ─────────────────────────────────────────────────────
  dependency-check:
    name: Dependency Vulnerability Check
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Checkout code
        uses: actions/checkout@v6
        with:
          ref: ${{ inputs.head_sha }}
      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - name: Install dependencies
        run: npm ci
      - name: Audit dependencies
        run: npm audit --audit-level=${{ inputs.audit_level }}

  # ─────────────────────────────────────────────────────
  # 2. Build the image in CI and push to GHCR
  # ─────────────────────────────────────────────────────
  build-and-push:
    name: Build & Push Image
    needs: dependency-check
    runs-on: ubuntu-latest
    timeout-minutes: 20
    permissions:
      contents: read
      packages: write
    outputs:
      image: ${{ steps.image-name.outputs.image }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v6
        with:
          ref: ${{ inputs.head_sha }}

      - name: Set lowercase image name
        id: image-name
        run: |
          echo "image=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract Docker image metadata
        id: meta
        uses: docker/metadata-action@v6
        with:
          images: ${{ steps.image-name.outputs.image }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=${{ inputs.image_tag }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v7
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}

  # ─────────────────────────────────────────────────────
  # 3. Scan the pushed image for OS / package CVEs
  # ─────────────────────────────────────────────────────
  image-scan:
    name: Container Security Scan
    needs: build-and-push
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      contents: read
      packages: read
    steps:
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Scan image with Trivy
        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
        with:
          image-ref: ${{ needs.build-and-push.outputs.image }}:${{ inputs.image_tag }}
          severity: CRITICAL,HIGH
          exit-code: '1'
          ignore-unfixed: true

  # ─────────────────────────────────────────────────────
  # 4. Pull the pre-built image on the server and restart
  # ─────────────────────────────────────────────────────
  deploy:
    name: Deploy to Server
    needs: [build-and-push, image-scan]
    runs-on: ubuntu-latest
    timeout-minutes: 10
    environment: ${{ inputs.environment }}   # activates env secrets + approval gate
    steps:
      - name: Deploy to Server via SSH
        uses: appleboy/[email protected]
        env:
          GHCR_TOKEN:  ${{ secrets.GHCR_TOKEN }}
          IMAGE:       ${{ needs.build-and-push.outputs.image }}
          IMAGE_TAG:   ${{ inputs.image_tag }}
          DEPLOY_PATH: ${{ inputs.deploy_path }}
        with:
          host:     ${{ secrets.SERVER_IP }}
          username: ${{ secrets.SERVER_USER }}
          key:      ${{ secrets.SERVER_SSH_KEY }}
          port:     22
          envs: GHCR_TOKEN,IMAGE,IMAGE_TAG,DEPLOY_PATH
          script: |
            echo "$GHCR_TOKEN" | docker login ghcr.io -u ${{ secrets.GHCR_USERNAME }} --password-stdin
            docker pull "$IMAGE:$IMAGE_TAG"
            cd "$DEPLOY_PATH"
            docker compose -f docker-compose.yml down --remove-orphans
            docker compose -f docker-compose.yml up -d
            docker image prune -f

Enter fullscreen mode Exit fullscreen mode


8. Design decisions worth understanding

Build once, deploy the artifact. The image is built and pushed in CI, and the
server merely pulls a pre-built tag. The server never runs docker build, so a deploy
is fast and reproducible, and a broken build can never leave a half-built container on
the box.

Scan before deploy, not after. The Trivy step sits between build and deploy with
exit-code: '1' on CRITICAL/HIGH findings, so a vulnerable image is blocked from ever
reaching a server. ignore-unfixed: true prevents the gate from failing on CVEs that
have no available fix — tune this to your risk tolerance.

The image tag must actually exist. A subtle, common bug: metadata-action here
emits a sha-<sha> tag and the environment tag (dev/staging/prod). If a later
step references a tag that was never produced — for example hardcoding :dev while the
metadata block only outputs latest — the scan and the server pull both fail on a
missing tag. This workflow avoids that by driving both the metadata type=raw tag and
the pull/scan references from the same inputs.image_tag.

Build args must match the Dockerfile. The build passes
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}. This only takes effect if the
Dockerfile declares a matching ARG NEXT_PUBLIC_API_URL. A mismatch between the arg
name here and the ARG in the Dockerfile is silent — the value simply never reaches
the build. Verify the names line up. (This is a frequent source of "the env var is
empty in the built app" confusion.)

Concurrency is per-environment. group: deploy-pmis-web-${{ inputs.environment }}
means a dev deploy and a prod deploy can run at the same time, but two deploys to the
same environment queue rather than clobber each other. cancel-in-progress: false
lets an in-flight deploy finish rather than being killed mid-way.

Quoting in the SSH script. Values are passed through envs: and referenced as
shell variables ($IMAGE, $DEPLOY_PATH) with quotes, rather than interpolated
directly into the script body. This avoids word-splitting and injection surprises if a
value ever contains unexpected characters.

9. Setup checklist

  1. Create the three Environments (dev, staging, production) and add required reviewers to production.
  2. Add the per-environment secrets to each environment and the shared SONAR_* secrets at the repository level.
  3. Confirm the Dockerfile declares every ARG the build passes (NEXT_PUBLIC_API_URL, plus any others you add).
  4. Ensure the compose project exists at the mapped path on each server (/home/apps/pmis-web-{dev,staging,prod}), with a docker-compose.yml that references the image and tag being pushed.
  5. Confirm the deploy user on each server has permission to run docker and pull from GHCR (the GHCR_TOKEN needs read:packages).
  6. Commit both workflow files. The quality workflow triggers on push to any of the three branches; because the deploy workflow is called via workflow_call, it does not need to exist on the default branch.

10. Adding a manual (on-demand) deploy path

To deploy an arbitrary branch to any environment on demand — useful for hotfixes or
re-deploys — add a workflow_dispatch trigger to the caller and hand its inputs to
the same reusable workflow. Add alongside the existing on: push: block:

on:
  push:
    branches: [develop, staging, main]
  workflow_dispatch:
    inputs:
      environment:
        description: "Environment to deploy"
        required: true
        type: choice
        options: [dev, staging, production]

Enter fullscreen mode Exit fullscreen mode

Then branch the resolver on github.event_name: for a manual run, read
github.event.inputs.environment and derive the tag and path from it; for a push,
use the branch mapping as before. The reusable deploy.yml needs no changes — it only
ever sees resolved inputs.

Remember: dispatching a workflow via the API/UI requires the file to exist on the
default branch. If you want the manual path but cannot touch the default branch, keep
the dispatch trigger on the caller (the quality workflow) and let it call the reusable
deploy — the caller is what needs to be dispatchable, and the reusable file stays
resolved from the caller's ref.

11. Troubleshooting

Nothing runs after the quality gate passes.
Check that the quality workflow actually triggers on push to the branch in question,
and that the resolver's branch guard includes it. If you migrated from workflow_run,
confirm you removed all github.event.workflow_run.* references — they are null
outside a workflow_run event and will cause guarded jobs to skip.

The deploy job is skipped even though build succeeded.
github.ref is refs/heads/<branch> only on a push event. On a pull request it is a
merge ref, so a github.ref == 'refs/heads/develop' style guard is false. Deploy on
push, not PR.

docker pull fails on the server with a missing tag.
The tag referenced at deploy time was never produced at build time. Ensure the
type=raw,value=<tag> in the metadata step matches the tag used in the scan and pull
steps — drive both from a single input.

The built app has empty public env vars.
The build-arg name does not match the Dockerfile ARG, so the value never reaches the
build. Align the names exactly.

Production deploys without waiting for approval.
The deploy job is missing environment: production, or the production environment has
no required reviewers configured. The pause comes from the Environment protection rule,
not from anything in the YAML beyond binding the job to that environment.

A reusable-workflow deploy sees no secrets.
The caller is missing secrets: inherit (or an explicit secrets: block). Called
workflows do not inherit secrets automatically.