Skip to main content

21 — deploy.yml decoded

Read this first: this chapter reads the real deployment workflow line by line — 253 lines that take a commit on main and put it in production. You built a smaller version in lesson 19; this is what the full one adds. Code is quoted inline so you can read this without the repository open.

Time: about 40 minutes. Earned by lessons 16, 17 and 18.

The graph​

test (reusable workflow)
├── build-backend ──┐
└── build-frontend ──┴── deploy-staging ── deploy-production

Triggers are push on main and staging, plus workflow_dispatch. Concurrency is group: deploy-production, cancel-in-progress: false — a running deploy is never killed halfway through.

The branch semantics are worth stating because they are not obvious: a push to staging deploys staging and stops. A push to main deploys staging and then promotes to production in the same run.

Tests are called, not copied​

test:
uses: ./.github/workflows/tests.yml

The suite is defined once, in tests.yml, which declares on: workflow_call. The pull-request run and the deploy run therefore execute the same tests — they cannot drift.

The tag​

Both build jobs compute it independently:

- id: tag
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"

${GITHUB_SHA:0:12} is bash substring expansion (lesson 04). Only build-backend republishes it as a job output, and both deploy jobs read needs.build-backend.outputs.tag — so there is exactly one tag per commit and both images carry it.

This is the whole design. Production runs the same image staging proved, not a rebuild from the same commit. A rebuild could differ: a dependency resolves to a new patch, a base image is updated. If production rebuilds, staging tested something else.

The conditional :latest tag​

tags: |
ghcr.io/jomariabejo/motorph-payroll-backend:${{ steps.tag.outputs.tag }}
${{ github.ref == 'refs/heads/main' && 'ghcr.io/jomariabejo/motorph-payroll-backend:latest' || '' }}

The A && B || C ternary from lesson 17. On main the second line becomes a :latest tag; on any other branch it becomes an empty string, which the tags: list ignores. Staging images therefore never claim to be latest.

Two cache sources​

cache-from: |
type=gha,scope=backend
type=registry,ref=ghcr.io/jomariabejo/motorph-payroll-backend:latest
cache-to: type=gha,mode=max,scope=backend

GitHub evicts cache entries after 7 idle days. On a quiet week the type=gha cache is gone and a cold Maven build downloads the world. The published :latest image backfills the gap — its layers are the same layers, and pulling them is faster than rebuilding.

That is the lesson 06 layer cache, arranged to survive an eviction policy.

The secret preflight​

echo "VPS_HOST length: ${#VPS_HOST}"
...
ok=1
[ -n "$VPS_HOST" ] || { echo "::error::VPS_HOST is empty"; ok=0; }

${#VAR} is length (lesson 04). Character counts, never values — so the log is safe to share, and an empty secret is caught here rather than three steps later as "missing server host".

The comment explains why it is five repetitive direct expansions rather than a tidy loop over ${!name}:

A diagnostic that can be wrong in the same direction as the fault it reports is worse than none.

An indirect expansion could itself be the broken thing. A check that can fail the same way as what it checks is not a check.

The SSH step​

env:
PROD_ENV: ${{ secrets.PROD_ENV_FILE }}
STAGE_ENV: ${{ secrets.STAGE_ENV_FILE }}
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT || 22 }}
command_timeout: 20m
envs: PROD_ENV,STAGE_ENV
script: |
set -euo pipefail
cd /srv/motorph
git pull --ff-only origin main
umask 077
[ -n "$PROD_ENV" ] || { echo "PROD_ENV_FILE secret is empty/unset" >&2; exit 1; }
printf '%s\n' "$PROD_ENV" > .env
printf '%s\n' "$STAGE_ENV" > .env.stage
./deploy/deploy.sh stage deploy "${{ needs.build-backend.outputs.tag }}"

Everything here was lesson 18, now in situ:

  • envs: not ${{ }} for the file contents — the security boundary. The script text contains $PROD_ENV; the value travels separately.
  • umask 077 before the redirect — the file lands 600 with no window.
  • The emptiness check before the redirect — otherwise an empty secret silently truncates .env to nothing and the stack fails to boot for an unrelated-looking reason.
  • printf '%s\n' not echo — echo mangles values beginning with -.
  • git pull --ff-only fails loudly if someone hand-edited a tracked file on the server, rather than merging or discarding it.

One deliberate oddity: the checkout always tracks main, even on a staging push. So a staging deploy runs that branch's images against main's configuration. The workflow says so in a comment. It is a trade, written down — which is the difference between a decision and an accident.

The thing that looks like a bug​

deploy-staging:
environment:
name: Production
url: https://stage.motorphenterprise.com

A job that deploys staging, declaring the Production environment.

From lesson 17: environment: is also a secrets scope selector. Secrets attached to an environment are readable only by jobs that declare it. Staging needs the same server credentials, so it declares the same environment.

Getting this wrong produced a failure reporting "missing server host", and then — after a partial fix — every secret reading as an empty string. The 14-line comment in the file is the most valuable paragraph in the repository, and "why does your staging job declare a Production environment?" is a question you can now answer properly.

Production never builds​

deploy-production:
needs: [build-backend, deploy-staging]
if: github.ref == 'refs/heads/main'

It waits for staging to pass its health gate, then promotes the tag staging just proved. No build step exists in this job at all — the images already exist in the registry.

Recap​

  • Build once, deploy twice. The tag is sha-<first 12 of the commit>, and production promotes rather than rebuilds.
  • A && B || C is GitHub's ternary; two cache sources exist because GHA evicts after 7 idle days.
  • Secrets are checked by length, never printed, and the check avoids being cleverer than the thing it checks.
  • envs: keeps secret content out of the script text; umask 077 keeps it off other users' eyes.
  • environment: scopes secrets, which is why a staging job declares Production.

Next: 22 — the gates decoded.