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_IPrather thanDEV_SERVER_IP).
Because each environment supplies its own value, there is no reason to prefix them
withDEV_. 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
- Create the three Environments (
dev,staging,production) and add required reviewers toproduction. - Add the per-environment secrets to each environment and the shared
SONAR_*secrets at the repository level. - Confirm the Dockerfile declares every
ARGthe build passes (NEXT_PUBLIC_API_URL, plus any others you add). - Ensure the compose project exists at the mapped path on each server
(
/home/apps/pmis-web-{dev,staging,prod}), with adocker-compose.ymlthat references the image and tag being pushed. - Confirm the deploy user on each server has permission to run
dockerand pull from GHCR (theGHCR_TOKENneedsread:packages). - 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.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.