Lesson 06 Push-to-Deploy
Theme:

Lab Guide — Push to GHCR and Run the Deploy Step

Lesson 06 · Cloud Infrastructure & DevOps · ~90 min

Prerequisites

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

  1. Go to your repository on GitHub → Settings → Environments → New environment
  2. Name it production (lowercase, exactly)
  3. Under Deployment protection rules, click Required reviewers and add your own GitHub username
  4. Under Environment secrets, add the three secrets:
    • DEPLOY_HOST — the IP address or hostname of your target machine
    • DEPLOY_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
  5. Optionally set a Deployment branch policy to “Selected branches” and add main
The 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:

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

  1. Make a trivial change to any file in the sample-app (e.g., add a comment to api/app.py) and push to main (or merge a PR that has a passing CI check)
  2. In GitHub Actions, watch the CI workflow complete (should already be green from L04)
  3. The Security & Sign workflow fires next — watch the Trivy scan, SBOM attestation, and cosign signing steps complete
  4. After Security & Sign shows green, the Deploy workflow starts — but immediately pauses at “Waiting for review”
  5. Click “Review deployments” in the Actions UI, select production, and click Approve and deploy
  6. Watch the verify steps, then the SSH step — the output should show docker compose pull downloading the new image and docker compose up -d recreating services
  7. On the target, verify: docker compose -f /opt/shortener/docker-compose.yml ps should 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

Troubleshooting