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.
- In GitHub Actions, grant
packages: write, log in toghcr.iowithGITHUB_TOKEN, and push images tagged with the commit SHA (plus a version tag for releases). - Add the
org.opencontainers.image.sourcelabel so the package is linked to the repository and inherits its access. - Keep the package private; give servers a read-only credential limited to pulling packages.
- On the server,
docker login ghcr.io, set the image tag in Compose,docker compose pull, run migrations, thendocker compose up -d. - Avoid deploying
latest; deploy immutable SHA tags or pin by digest. - 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
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.
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}}'
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
- Build the full pipeline with Deploy Django with GitHub Actions CI/CD.
- Run the pulled images with Deploy Django with Docker Compose in Production.
- Support ARM hosts with Build Multi-Architecture Django Docker Images.
- If a deploy seems to run old code, see Fix Django Code Changes Not Showing After Deploy.
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.