Skip to content

Deploy Docker Applications with GitHub Actions

Lesson 4 of 7Intermediate4 min readGitHub Actions & CI/CD · Continuous DeliveryVerified: docker/build-push-action v7, docker/login-action v4, docker/metadata-action v6, August 2026

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.

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:

RegistryAuthenticationLong-lived secret?
GHCRGITHUB_TOKENNo
Amazon ECROIDC → aws-actions/amazon-ecr-login@v2No
Azure ACROIDC → azure/login@v3 then az acr loginNo
Google Artifact RegistryOIDC → google-github-actions/auth@v3No
Docker HubAccess token in a secretYes

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.

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.

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:

If you do run docker compose on a host, the deployment sequence that avoids downtime is:

  1. 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.

  2. Start the new container alongside the old one, on a different internal port.

  3. Poll the new container’s health endpoint until it responds, with a bounded number of attempts.

  4. Switch the proxy to the new container.

  5. 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.

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.

  1. Extend the CI workflow from Docker CI so the build job exposes the digest as a job output.

  2. Add a deploy job with needs: build and an environment that requires your approval. Have it print the full @sha256:… reference — a real deployment is not needed to learn the shape.

  3. Merge twice. Confirm the two runs produced different digests and that both images exist in the registry.

  4. Add the workflow_dispatch rollback workflow above. Run it with the first digest and confirm it resolves.

  5. 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.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Want production-ready workflow templates? The Professional Toolkit has five, with permissions set correctly.