Docker Images in GitHub Packages (Private Repos)

Getting private Maven packages working is useful, but most services deploy as containers. The next step is to package the application as a Docker image and publish it to GitHub Container Registry (GHCR), then keep costs sane by cleaning up what is left behind. This is a minimal, repeatable setup that builds a Spring Boot jar, turns it into an image, pushes it to GHCR, and removes untagged images so storage does not creep up over time.

For background, see the earlier posts on publishing Maven packages and Dependabot for private packages. This post focuses on containers only.

Dockerfile

Nothing fancy is needed for a Spring Boot service. A slim Java base, copy the built jar, and run it on start.

1
2
3
4
FROM openjdk:17-jdk-slim
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

This same Dockerfile should work locally and in CI as long as the jar lands in target/ before the image build step.

Workflow structure

The workflow builds the jar first, stores it as an artifact, then downloads it in a second job that builds and publishes the container. Keeping the build separate from the image step makes it easy to reuse the same build process for pull requests without producing images on every branch.

Shared environment

Use the repository name as the image name and publish to GHCR.

1
2
3
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

Build the jar and upload it

Build with Maven and upload the resulting jar. This mirrors local development and keeps the Dockerfile simple.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
- name: Build and Test with Maven
  run: mvn -B -Pgithub install --file pom.xml
  env:
    GITHUB_USER_REF: ${{ secrets.MVN_USER }}
    GITHUB_TOKEN_REF: ${{ secrets.MVN_KEY }}

- name: Upload build artifacts
  uses: actions/upload-artifact@v4
  with:
    name: app-jars
    path: target/*.jar

Download, build, and push the image

Download the jar to target/ so the Dockerfile can find it, then log in to GHCR, generate tags and labels, and push.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
- name: Download build artifacts
  uses: actions/download-artifact@v4
  with:
    name: app-jars
    path: target

- name: Log in to the Container registry
  uses: docker/login-action@v3
  with:
    registry: ${{ env.REGISTRY }}
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata (tags, labels) for Docker
  id: meta
  uses: docker/metadata-action@v5
  with:
    images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

- name: Build and push Docker image
  id: push
  uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ${{ steps.meta.outputs.tags }}
    labels: ${{ steps.meta.outputs.labels }}

Permissions: the job needs packages: write to push, plus attestations: write and id-token: write if you enable provenance later. Add these at the job level:

1
2
3
4
5
permissions:
  contents: read
  packages: write
  attestations: write
  id-token: write

The docker/metadata-action uses sensible defaults. By default it tags images with the branch and commit. When a branch tag is reused, GHCR moves the tag to the latest image but keeps the older images untagged.

Keep storage under control

Untagged images accumulate after each push. To avoid paying for storage overages, add a cleanup job that removes untagged images for this repository after a successful push.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
remove-package-versions:
  needs: push
  name: Remove Untagged Docker Images
  runs-on: ubuntu-latest
  steps:
    - name: Checkout repository
      uses: actions/checkout@v4

    - name: Get repository name
      id: repo-name
      uses: MariachiBear/get-repo-name-action@v1.1.0
      with:
        string-case: 'lowercase'

    - name: Purge untagged containers
      uses: dylanratcliffe/delete-untagged-containers@main
      with:
        package_name: ${{ steps.repo-name.outputs.repository-name }}
        token: ${{ secrets.MVN_KEY }}

Use a Personal Access Token for the deletion step if required by the action you choose. Reusing the same PAT that authenticates Maven is fine as long as it has the repo and write:packages scopes.

Troubleshooting checklist

  • Push denied: confirm job permissions include packages: write.
  • No jar in image build: verify the download step writes to target/ so the Dockerfile’s ARG JAR_FILE=target/*.jar can find it.
  • Wrong image name: check IMAGE_NAME is set to ${{ github.repository }} and that REGISTRY is ghcr.io.
  • Storage climbing: confirm the cleanup job runs after pushes and that the PAT has permission to delete untagged images.
  • Local dev parity: run mvn -B -Pgithub install locally, then docker build -t test . to confirm the Dockerfile works outside CI.

That is the entire loop. Build the jar once, reuse it for the container image, publish to GHCR with the built in GITHUB_TOKEN, and prune what you do not need. The result is predictable, cheap to run, and easy to copy across repositories.

Thanks for reading. If you have a leaner cleanup approach or a tagging convention that scales for multi environment deploys, share it so others can benefit.


↤ Previous Post
Next Post ↦