Lab Guide — Push to GHCR and Run the Deploy Step
Lesson 06 · Cloud Infrastructure & DevOps · ~90 min
Prerequisites
- L05 lab completed:
security.ymlis building, scanning, and signing images on merges tomain; the signed images are visible in your GitHub repository’s Packages tab - Branch protection on
mainrequires the CI checks fromci.ymlto pass before merging - You have admin access to the repository (required to create Environments under Settings)
- A machine you can SSH to: either a cloud VM or a local VM (see Step 1 below)
Step 1 — Provision a stand-in deploy target
The deploy workflow will SSH to a machine and run docker compose pull && docker compose up -d. You need a machine that can accept SSH connections from GitHub Actions runners (outbound from 140.82.112.0/20 and related GitHub Actions IP ranges). Two options:
Option A — Local VM with public SSH (free, slightly fiddly): Use multipass (macOS/Linux/Windows) or lima (macOS) to create a local Ubuntu VM. Then use a tool such as ngrok tcp 22 or Tailscale to expose its SSH port. This works for experimentation but requires the tunnel to stay open during the lab.
Option B — Small cloud VM (~$5/month): Spin up the smallest available instance on DigitalOcean (Droplet, $4/month), Hetzner (CX11, €3.29/month), or GCP (e2-micro, free-tier eligible). These have a public IP that GitHub Actions can reach directly.
Whichever option you choose, do the following:
# 1. Generate a dedicated deploy key on your laptop
ssh-keygen -t ed25519 -C "shortener-deploy" -f ~/.ssh/shortener_deploy
# 2. Copy the PUBLIC key to the target machine
ssh-copy-id -i ~/.ssh/shortener_deploy.pub user@your-target-host
# 3. Test that passwordless SSH works
ssh -i ~/.ssh/shortener_deploy user@your-target-host docker --version
The last command should print the Docker version without any password prompt. If it does, the SSH key is wired up correctly.
Step 2 — Define the production environment in GitHub
- Go to your repository on GitHub → Settings → Environments → New environment
- Name it
production(lowercase, exactly) - Under Deployment protection rules, click Required reviewers and add your own GitHub username
- Under Environment secrets, add the three secrets:
DEPLOY_HOST— the IP address or hostname of your target machineDEPLOY_USER— the SSH username (e.g.,ubuntu,root, or whatever you set up)DEPLOY_KEY— the entire contents of~/.ssh/shortener_deploy(the private key), including the-----BEGIN OPENSSH PRIVATE KEY-----and-----END OPENSSH PRIVATE KEY-----lines
- Optionally set a Deployment branch policy to “Selected branches” and add
main
DEPLOY_KEY secret must include the full private key file content, newlines and all. A common mistake is copying only the base64 body without the header/footer lines. The SSH action will silently fail with a “permission denied (publickey)” error if the key is malformed.
Step 3 — Refactor security.yml to emit digest outputs
The deploy.yml workflow uses workflow_run to trigger after Security & Sign finishes. For deploy.yml to know which digests to verify, security.yml needs to expose them as job outputs. The refactor changes the sign job from a matrix (which makes outputs awkward) to two named steps — one for api, one for worker — each assigned an id.
Replace the jobs.sign section in cloud-devops/sample-app/.github/workflows/security.yml with:
jobs:
sign:
runs-on: ubuntu-latest
outputs:
api-digest: ${{ steps.api.outputs.digest }}
worker-digest: ${{ steps.worker.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: api
uses: docker/build-push-action@v5
with:
context: ./cloud-devops/sample-app/api
push: true
tags: |
ghcr.io/${{ github.repository }}/api:${{ github.sha }}
ghcr.io/${{ github.repository }}/api:latest
- id: worker
uses: docker/build-push-action@v5
with:
context: ./cloud-devops/sample-app/worker
push: true
tags: |
ghcr.io/${{ github.repository }}/worker:${{ github.sha }}
ghcr.io/${{ github.repository }}/worker:latest
- uses: sigstore/cosign-installer@v3
- name: Sign api
env:
COSIGN_EXPERIMENTAL: "true"
run: cosign sign --yes ghcr.io/${{ github.repository }}/api@${{ steps.api.outputs.digest }}
- name: Sign worker
env:
COSIGN_EXPERIMENTAL: "true"
run: cosign sign --yes ghcr.io/${{ github.repository }}/worker@${{ steps.worker.outputs.digest }}
Why this change? Matrix jobs produce outputs that are indexed by matrix key, making them awkward to reference from a downstream workflow_run job (which can’t consume matrix outputs at all without artifact workarounds). Splitting into named steps with stable IDs keeps the digests addressable as steps.api.outputs.digest and steps.worker.outputs.digest.
Step 4 — Create deploy.yml
Create the file cloud-devops/sample-app/.github/workflows/deploy.yml with this content (it already exists in the sample-app if you are working from the course repo):
name: Deploy
on:
workflow_run:
workflows: ["Security & Sign"]
types: [completed]
branches: [main]
permissions:
contents: read
id-token: write
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
deploy:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
environment:
name: production
url: https://shortener.example.com
steps:
- uses: actions/checkout@v4
- uses: sigstore/cosign-installer@v3
- name: Verify api signature
env:
COSIGN_EXPERIMENTAL: "true"
run: |
cosign verify \
--certificate-identity-regexp "https://github.com/${{ github.repository }}/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/${{ github.repository }}/api:${{ github.event.workflow_run.head_sha }}
- name: Verify worker signature
env:
COSIGN_EXPERIMENTAL: "true"
run: |
cosign verify \
--certificate-identity-regexp "https://github.com/${{ github.repository }}/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/${{ github.repository }}/worker:${{ github.event.workflow_run.head_sha }}
- name: Deploy to VM (placeholder)
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
set -euo pipefail
export IMAGE_TAG=${{ github.event.workflow_run.head_sha }}
cd /opt/shortener
docker compose pull
docker compose up -d
docker compose ps
Key points about this workflow:
workflow_runtrigger:deploy.ymlfires only after “Security & Sign” completes successfully onmain. This eliminates the race between signing and deploying.- Verify before deploy: both cosign verify steps run before the SSH step. If either fails, the deploy is aborted and the target is never touched.
github.event.workflow_run.head_sha: this is the SHA of the commit that Security & Sign built and signed, not the deploy workflow’s own SHA (though they will be the same for pushes to main; they are semantically distinct).- Concurrency:
cancel-in-progress: falsequeues a second deploy rather than cancelling the first. A mid-deploy cancellation can leave the target in a half-updated state.
Step 5 — Bootstrap the target VM
SSH into your target machine and run:
# Install Docker (Ubuntu example)
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
newgrp docker
# Install docker-compose plugin (if not bundled)
sudo apt-get install -y docker-compose-plugin
# Log in to GHCR with a PAT scoped to read:packages
echo $GHCR_PAT | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin
# Create the app directory
sudo mkdir -p /opt/shortener
sudo chown $USER:$USER /opt/shortener
Create /opt/shortener/docker-compose.yml on the target (substitute your repository org and name):
services:
api:
image: ghcr.io/YOUR_ORG/YOUR_REPO/api:${IMAGE_TAG:-latest}
ports:
- "8080:8080"
worker:
image: ghcr.io/YOUR_ORG/YOUR_REPO/worker:${IMAGE_TAG:-latest}
Pull once manually to confirm the GHCR credentials work:
IMAGE_TAG=latest docker compose -f /opt/shortener/docker-compose.yml pull
If the pull succeeds, the target is bootstrapped and ready for the automated deploy.
Step 6 — Push, observe sign, observe deploy
- Make a trivial change to any file in the sample-app (e.g., add a comment to
api/app.py) and push tomain(or merge a PR that has a passing CI check) - In GitHub Actions, watch the CI workflow complete (should already be green from L04)
- The Security & Sign workflow fires next — watch the Trivy scan, SBOM attestation, and cosign signing steps complete
- After Security & Sign shows green, the Deploy workflow starts — but immediately pauses at “Waiting for review”
- Click “Review deployments” in the Actions UI, select
production, and click Approve and deploy - Watch the verify steps, then the SSH step — the output should show
docker compose pulldownloading the new image anddocker compose up -drecreating services - On the target, verify:
docker compose -f /opt/shortener/docker-compose.yml psshould show both services running with the new image tag
Step 7 — Trigger a rollback
There are two easy ways to test rollback in this lab:
Option A — Re-run the previous Deploy workflow: In the Actions tab, find the Deploy run that immediately preceded the current one. Click into it and select “Re-run all jobs”. Because the workflow reads github.event.workflow_run.head_sha from the original event payload, the re-run will use the previous SHA and deploy the previous image. Approve it when the environment gate fires.
Option B — Manual rollback on the target: SSH to the target and run:
IMAGE_TAG=<previous-sha> docker compose -f /opt/shortener/docker-compose.yml up -d
Replace <previous-sha> with the commit SHA of the previous successful deploy. Check the Deploy workflow run history to find it.
What you should leave this lab with
- A real (or stand-in) deploy target running the latest signed images from GHCR
- A manual approval gate working: the Deploy job pauses until you approve
- At least one tested rollback (either via workflow re-run or manual
IMAGE_TAGoverride) - Understanding of why the verify steps must come before the SSH step
Troubleshooting
- “Deploy workflow doesn’t trigger after Security & Sign” —
workflow_runonly fires on the default branch (usuallymain). Confirm both workflows are committed tomain, not just a feature branch. Also,workflow_runevents can lag by up to one minute after the triggering workflow finishes. - “SSH step fails with permission denied (publickey)” — the
DEPLOY_KEYsecret is mis-pasted. Open the secret in Settings → Environments → production, delete it, and re-paste the entire private key file contents including the-----BEGIN OPENSSH PRIVATE KEY-----header and-----END OPENSSH PRIVATE KEY-----footer. - “
docker compose pullfails with unauthorized” — the GHCR PAT on the target has expired or was scoped incorrectly. Re-runecho $GHCR_PAT | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdinon the target with a fresh PAT that has theread:packagesscope. - “Verification fails for the new digest” — the cosign verify step uses
github.event.workflow_run.head_shaas the tag. If the image at that tag wasn’t pushed by Security & Sign (e.g., the push step failed silently), there is nothing to verify. Check the Security & Sign workflow run logs to confirm the push and sign steps both completed.