From a signed image in GHCR to a running container in the cloud — with environment-scoped secrets and a blue/green pattern.
Cloud & DevOps Module 2
mainL06 closes that gap: we wire a deploy job that fires after Security & Sign and SSHes to a target to run the new image.
docker run on a VM by hand
GitOps reconciliation — L14
Today: one step right of manual — a CI job invokes the deploy on push to main.
| Registry | Auth in GHA | Cost / notes |
|---|---|---|
GHCR (ghcr.io) |
GITHUB_TOKEN — no extra setup |
Free with the repo; tight GitHub integration |
ECR (<acct>.dkr.ecr.<region>.amazonaws.com) |
OIDC federation via aws-actions/configure-aws-credentials@v4 |
AWS IAM-native; no long-lived AWS keys needed |
| Docker Hub | Username + PAT secret | Rate-limited free tier; easy mindshare, watch pull quota in CI |
We use GHCR for all course labs. The lab guide includes a side note on the ECR variant.
GitHub Environments (Settings → Environments) let you scope secrets to specific environments such as production or staging. The production environment can require manual approval before its secrets are released to a job.
main → production)deploy:
environment:
name: production
url: https://shortener.example.com
When this job runs, GitHub checks the production environment policy. If a required reviewer is configured, the job blocks until that reviewer approves in the GitHub Actions UI. Only then are the production secrets injected and the steps begin executing.
url: field powers the “deployed to” link in the GitHub deployment history- uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
docker pull ghcr.io/$ORG/$REPO/api:${{ github.sha }}
docker compose -f /opt/shortener/docker-compose.yml up -d
DEPLOY_HOST, DEPLOY_USER, DEPLOY_KEY are production environment secretsappleboy/ssh-action is a placeholder — L07 replaces it with a Terraform-provisioned targetdocker compose up -d recreates only changed services; volumes and networks persistkubectl apply against a Kubernetes cluster
┌──────────────────────────────────────────────────┐
│ Load Balancer / Router │
└─────────────────┬────────────────────────────────┘
│ ◀── traffic flows here
┌─────────▼──────────┐ ┌────────────────────┐
│ BLUE (live) │ │ GREEN (idle) │
│ api:sha-a1b2c3d │ │ api:sha-e4f5g6h │
└────────────────────┘ └────────────────────┘
- name: Deploy to blue
run: ./deploy.sh blue ${{ github.sha }}
- name: Smoke test blue
run: curl -fsS https://blue.shortener.example.com/healthz
- name: Flip traffic
run: ./flip.sh blue
./deploy.sh and ./flip.sh scripts are stand-ins for the actual routing primitiveDeploying by digest (@sha256:…) instead of a mutable tag (:latest) prevents the surprise of a silently mutated tag.
# Risky: tag can be overwritten
docker pull ghcr.io/org/repo/api:latest
# Safe: digest is content-addressable, immutable
docker pull ghcr.io/org/repo/api@sha256:abc123...
Three options, from simplest to most GitOps-consistent:
| Option | How | Speed |
|---|---|---|
| Re-deploy previous digest | Re-run the previous Deploy workflow run in the Actions UI, or SSH and set IMAGE_TAG to the previous digest manually |
Minutes — needs the digest recorded |
| Blue/green flip | Run ./flip.sh <old-slot> to point the router back |
Seconds — only available with blue/green standing |
| Git revert + auto-deploy | Revert the offending commit; CI re-deploys automatically | Slowest; most consistent with GitOps (L14) |
All three need the previous digest written down somewhere. Design your pipeline to emit it.
deploy.yml workflow that triggers via workflow_run after security.yml succeeds on mainproduction with a manual approval gateappleboy/ssh-action that pulls the new image and runs docker compose up -d on the target VMcancel-in-progress: false so two quick pushes queue rather than raceEnd state: push to main → CI (L04) → Security & Sign (L05) → Deploy (L06). Three automated stages, one manual gate, one running container.
:latest — you can’t deterministically roll back to “the previous :latest” because the tag is mutable; always deploy by commit SHA or digestenvironment.url — the GitHub UI deployment history shows a blank “deployed to” link; the deployment event still fires but is harder to traceconcurrency: block