Docker CI built an image and refused to publish it from a pull request. This page publishes it, and then deploys it — and the single idea that makes both steps safe is the same one: a digest, not a tag.
Publishing to GHCR needs no secret
Section titled “Publishing to GHCR needs no secret”GitHub Container Registry accepts the automatically-provided workflow token, so publishing to your own organisation’s registry requires nothing stored:
permissions: contents: read packages: write
steps: - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}secrets.GITHUB_TOKEN is minted per run, scoped to this repository, and revoked when the job ends.
There is no personal access token to create, share or rotate — which also means there is nothing for
a compromised workflow to steal that outlives the run.
For Docker Hub, ECR, ACR or Artifact Registry the mechanism differs:
| Registry | Authentication | Long-lived secret? |
|---|---|---|
| GHCR | GITHUB_TOKEN | No |
| Amazon ECR | OIDC → aws-actions/amazon-ecr-login@v2 | No |
| Azure ACR | OIDC → azure/login@v3 then az acr login | No |
| Google Artifact Registry | OIDC → google-github-actions/auth@v3 | No |
| Docker Hub | Access token in a secret | Yes |
Docker Hub is the outlier. If you must use it, create a token rather than using the account password, scope it to read-write on the one repository, store it as an environment secret so only the deploy environment can read it, and rotate it on a schedule.
Deploy by digest
Section titled “Deploy by digest”jobs: build: outputs: digest: ${{ steps.build.outputs.digest }} steps: - id: build uses: docker/build-push-action@v7 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
deploy: needs: build steps: - run: echo "deploying ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }}"What it doesPasses the immutable content hash of the image built by CI to the deployment, instead of a tag.
Why we run itA tag is a mutable pointer. `main` means a different image after every merge, and two deployments minutes apart can resolve the same tag to different content — so 'roll back to the previous image' has no well-defined answer.
Expected resultDeployments that name exactly one image forever, and a rollback that is a single string change.
The @sha256:… form is not cosmetic. It is the difference between “deploy whatever main points at
right now” and “deploy the exact bytes that passed CI”. Every orchestrator in this cluster accepts a
digest reference wherever it accepts a tag.
Still publish human-readable tags — v1.4.2, main, sha-a1b2c3 — because people need to find
images in a registry UI. Use them for discovery; use the digest for deployment.
Deployment targets
Section titled “Deployment targets”Container platforms — ECS, Cloud Run, Container Apps — take an image reference and handle the rollout. These are covered per-cloud in AWS, Google Cloud and Azure. They are the easiest case because the platform owns the revision history, which is what makes rollback real.
Kubernetes is its own page.
A plain Docker host — a single VM running docker compose — is the case where people most often
reach for the wrong tool. The instinct is to have the workflow SSH into the server:
Rolling updates on a single host
Section titled “Rolling updates on a single host”If you do run docker compose on a host, the deployment sequence that avoids downtime is:
-
Pull the new image by digest before stopping anything. A pull that fails — network, auth, deleted image — must not take the running service down with it.
-
Start the new container alongside the old one, on a different internal port.
-
Poll the new container’s health endpoint until it responds, with a bounded number of attempts.
-
Switch the proxy to the new container.
-
Stop the old container — but keep the image, because that is your rollback.
Step 5 is the one that gets pruned for disk space, and then rollback means rebuilding from source. Keep at least the previously-deployed image.
Rollback
Section titled “Rollback”Rolling back a container deployment is straightforward if the previous image still exists:
on: workflow_dispatch: inputs: digest: description: "Image digest to roll back to (sha256:…)" required: true
jobs: rollback: runs-on: ubuntu-latest environment: production steps: - name: Deploy the specified digest run: echo "deploying ghcr.io/OWNER/REPO@${DIGEST}" env: DIGEST: ${{ inputs.digest }}A workflow_dispatch rollback with the digest as an input is worth building before you need it.
During an incident, the last thing anyone should be doing is writing a deployment workflow.
Two things break this: a registry retention policy that deletes untagged images — which is exactly what a digest-only reference looks like to a cleanup job — and a database migration that the old image cannot run against. Tag the images you deploy so retention keeps them, and keep migrations backwards-compatible.
Exercise
Section titled “Exercise”-
Extend the CI workflow from Docker CI so the build job exposes the digest as a job output.
-
Add a deploy job with
needs: buildand anenvironmentthat requires your approval. Have it print the full@sha256:…reference — a real deployment is not needed to learn the shape. -
Merge twice. Confirm the two runs produced different digests and that both images exist in the registry.
-
Add the
workflow_dispatchrollback workflow above. Run it with the first digest and confirm it resolves. -
Look at your registry’s retention settings. Decide, deliberately, how far back a rollback can reach — and write it down somewhere your on-call rota can find it.