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 077before the redirect — the file lands600with no window.- The emptiness check before the redirect — otherwise an empty secret silently truncates
.envto nothing and the stack fails to boot for an unrelated-looking reason. printf '%s\n'notecho—echomangles values beginning with-.git pull --ff-onlyfails 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 || Cis 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 077keeps it off other users' eyes.environment:scopes secrets, which is why a staging job declaresProduction.
Next: 22 — the gates decoded.