Deploy Django Docker Images from GitHub Container Registry

Build Django images in GitHub Actions, push them to GHCR with immutable tags, pull them on servers with read-only credentials, deploy by tag or digest, and clean up old versions.

Problem statement

Building Docker images on the production server during each deploy is slow, uses the server's CPU and memory while it serves traffic, and means each server may build a slightly different image. The cleaner model is to build once in CI, push to a registry, and have servers pull the exact image that passed tests.

GitHub Container Registry (GHCR) is a natural registry for projects hosted on GitHub: Actions can push with the built-in token, and permissions can follow the repository. This guide sets up the build and push, a least-privilege pull credential for servers, tag and digest strategies, and cleanup.

Build once, deploy everywhereGitHub Actionstest and buildGHCRtag = commit SHAServer pullread-only tokenCompose upexact image runsServers never build images; they pull the tag or digest that CI produced and tested.
  1. In GitHub Actions, grant packages: write, log in to ghcr.io with GITHUB_TOKEN, and push images tagged with the commit SHA (plus a version tag for releases).
  2. Add the org.opencontainers.image.source label so the package is linked to the repository and inherits its access.
  3. Keep the package private; give servers a read-only credential limited to pulling packages.
  4. On the server, docker login ghcr.io, set the image tag in Compose, docker compose pull, run migrations, then docker compose up -d.
  5. Avoid deploying latest; deploy immutable SHA tags or pin by digest.
  6. Prune old untagged and outdated versions on a schedule.

Step-by-step solution

1. Build and push from Actions

# .github/workflows/image.yml
name: image
on:
  push:
    branches: [main]
    tags: ["v*"]

jobs:
  image:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    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: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,format=long
            type=semver,pattern={{version}}
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
from a commit to a running container1Actions builds the imageon push to main2Push with GITHUB_TOKENpackages: write3Tag with the commit SHAplus a moving tag4Server logs in read-onlyread:packages only5Pull that tag and restartcompose pull, up -dThe server never builds; it only pulls what CI produceddjango-deployment.com
Building in CI means every server runs the same bytes that the tests ran against.

docker/metadata-action generates tags (sha-<full sha> for every push, 1.4.0 for a v1.4.0 tag) and OCI labels including org.opencontainers.image.source, which links the package to the repository. Image names on GHCR must be lowercase; github.repository is lowercase for most repositories but not guaranteed, so lowercase it if your organisation or repository name contains capitals.

Run your test job before this one (needs: test) so only tested commits produce deployable images.

2. Package visibility and access

New packages are private by default. With the source label, the package appears on the repository page and you can let it inherit the repository's access permissions in the package settings. Keep it private unless the image is meant to be public; images often contain source code and sometimes accidental secrets.

3. Give servers a read-only credential

Servers need to pull, never push. Create a credential with only the read:packages scope, for example a classic personal access token on a dedicated machine account that has read access to the repository. Check GitHub's current documentation for which token types the container registry accepts in your organisation.

give the server the least it needsAvoid✕your personal token with repo scope✕a token that can push images✕the token in the Compose fileUse✓a machine account or scoped token✓read:packages only✓docker login once, stored as root-onlyA stolen pull token can read images; it cannot publish a malicious onedjango-deployment.com
Give every server its own token, so one can be revoked without touching the others.

On the server:

echo "$GHCR_READ_TOKEN" | docker login ghcr.io -u machine-user --password-stdin

Docker stores the credential in ~/.docker/config.json of the user running the command. Restrict that file's permissions, or configure a credential helper, and rotate the token periodically.

4. Reference the image in Compose

# /srv/myapp/compose.yaml
services:
  web:
    image: ghcr.io/example/myapp:${APP_TAG:?set APP_TAG}
    command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3
    env_file: /srv/myapp/app.env
    restart: unless-stopped
  worker:
    image: ghcr.io/example/myapp:${APP_TAG:?set APP_TAG}
    command: celery -A config worker --loglevel=INFO
    env_file: /srv/myapp/app.env
    restart: unless-stopped

${APP_TAG:?...} makes Compose refuse to start without a tag, so no one deploys an unintended default.

5. Deploy a specific version

#!/usr/bin/env bash
# /srv/myapp/deploy.sh <tag>
set -euo pipefail
export APP_TAG="$1"
cd /srv/myapp

docker compose pull web worker
docker compose run --rm web python manage.py migrate --noinput
docker compose up -d web worker
echo "$APP_TAG" > /srv/myapp/DEPLOYED_TAG

sleep 5
curl -fsS -H "Host: example.com" http://127.0.0.1:8000/healthz/ > /dev/null && echo "healthy: $APP_TAG"

Run it from CI over SSH after the image is pushed (deploy.sh sha-<commit>), or by hand. The web service must publish port 8000 on localhost (or be reachable from your proxy) for the health check to work as written.

6. Tags versus digests

A tag can be moved to a different image by pushing again; a digest cannot change. SHA-based tags are effectively immutable in practice if CI is the only thing that pushes, but pinning by digest removes all doubt:

docker buildx imagetools inspect ghcr.io/example/myapp:sha-3f9c2a1... --format '{{json .Manifest.Digest}}'
which reference can change under you?Can change laterUse it for:latest✕yes, every pushnothing in production:main!yes, every mergestaging that follows main:sha-3f9c2e1✓only if re-pusheddeploys and rollbacks@sha256:…✓neverstrict pinning and auditsDeploy by SHA tag and a rollback is just the previous SHAdjango-deployment.com
A digest is immutable by definition; a tag is only as stable as your process.

Then deploy ghcr.io/example/myapp@sha256:<digest>. Record the digest alongside the tag in your deploy log.

Never deploy latest in production: you cannot tell which code is running, and rolling back to "the previous latest" is impossible.

7. Clean up old versions

Every push adds a version, and storage for private packages counts against your plan. Prune on a schedule while keeping recent releases:

# .github/workflows/prune-images.yml
on:
  schedule: [{ cron: "0 3 * * 0" }]
jobs:
  prune:
    runs-on: ubuntu-latest
    permissions: { packages: write }
    steps:
      - uses: actions/delete-package-versions@v5
        with:
          package-name: myapp
          package-type: container
          min-versions-to-keep: 30
          delete-only-untagged-versions: "false"

Keep enough versions to roll back to anything you might need, including images currently deployed in every environment.

On servers, remove old local images too: docker image prune -a --filter "until=168h" --force.

Verification check:

docker compose ps
docker inspect --format '{{.Config.Image}} {{.Image}}' "$(docker compose ps -q web)"
cat /srv/myapp/DEPLOYED_TAG

The running container's image should match the tag or digest you deployed.

Explanation

The registry is the hand-off point between CI and production. CI turns a commit into an immutable image and publishes it; servers only authenticate, pull, and run. Because the same artifact moves through staging and production, what you tested is what runs, and a rollback is just deploying the previous tag. Least-privilege credentials on servers keep a compromised host from pushing a malicious image that other environments would then pull.

Edge cases / notes

Rate limits and outages

If GHCR is unavailable, servers cannot pull new images, but running containers are unaffected. Pull before stopping old containers, as the script above does, so a failed pull never takes the site down.

Multi-architecture images

For ARM servers, build with platforms: linux/amd64,linux/arm64; the tag then covers both and each server pulls its variant.

Rollback

deploy.sh sha-<previous commit> redeploys the earlier image. Migrations are not reversed, so keep them backward compatible.

When to automate this

Chain the workflows: tests, image build and push, deploy to staging, smoke test, then a manually approved production deploy using the same tag. The deploy script above is the only piece that runs on servers.

Next steps

FAQ

How do I push to GitHub Container Registry from GitHub Actions?

Grant the job packages: write, log in to ghcr.io with GITHUB_TOKEN, and push with docker/build-push-action.

How does a production server pull a private GHCR image?

Log in with a read-only credential that has the read:packages scope, ideally from a dedicated machine account.

Should I deploy the latest tag?

No. Deploy immutable tags such as commit SHAs or version numbers, or pin by digest, so you always know what is running and can roll back.

Why is my GHCR package not visible on the repository?

The image lacks the org.opencontainers.image.source label linking it to the repository. Add it via docker/metadata-action or a Dockerfile LABEL.

Learning path · step 9 of 12

Docker, from image to production

Keep going