Skip to content

Publishing Images to Harbor

How to build and publish locally-defined Docker images to the internal Harbor registry at harbor.toolsera.lan, and how to consume them from compose stacks.

Use this when a stack has a Dockerfile (e.g. docker-compose-apps/ubuntu-vm/apps/github-runner/Dockerfile) and you want the resulting image distributed across the fleet rather than rebuilt on every host.

git tag image/<name>-<version> → .github/workflows/publish-docker-image.yml
│ │
└─ push ├─ runs on [self-hosted, homelab, deb-builder]
├─ bws run -- docker login + buildx --push
└─ pushes :<version> and :latest to Harbor
harbor.toolsera.lan/toolsera/<name>

Harbor’s toolsera project is set to public, so any homelab host can pull without docker login. Pushing always requires auth — Harbor enforces this regardless of project visibility — so the workflow logs in with credentials fetched from BWS (HARBOR_USERNAME / HARBOR_PASSWORD) before pushing.

One-time setup before the first workflow run:

  1. Harbor project — create a project named toolsera in the Harbor UI (harbor.toolsera.lan → Projects → New Project). Set access level to Public so pulls from homelab hosts don’t need credentials.
  2. Robot account — under the toolsera project, create a Harbor robot with push + pull on repository. Note the auto-generated robot name (e.g. robot$toolsera+publisher) and secret.
  3. BWS secrets — add to the BWS project this repo uses:
    • HARBOR_USERNAME = the full robot account name (with robot$ prefix)
    • HARBOR_PASSWORD = the robot secret
  1. Make sure the image is in the lookup table in .github/workflows/publish-docker-image.yml. Each entry maps a logical image name to its build-context directory:

    Terminal window
    case "$IMAGE" in
    github-runner) ctx="docker-compose-apps/ubuntu-vm/apps/github-runner" ;;
    # add new images here
    esac

    If you’re shipping a brand-new image, add a line. Otherwise skip to step 2.

  2. Tag and push:

    Terminal window
    git tag image/github-runner-0.1.0
    git push origin image/github-runner-0.1.0

    Tag format is image/<name>-<version>. Use semantic versioning for <version>.

  3. Watch the workflow under Actions → “Publish Docker image to Harbor”. On success Harbor will hold two new tags:

    • harbor.toolsera.lan/toolsera/<name>:<version> (immutable, pin to this)
    • harbor.toolsera.lan/toolsera/<name>:latest (floating)
  4. Roll out to hosts. If the compose file pins :latest, run the standard restart playbook to pick up the new image:

    Terminal window
    ansible-playbook -i inventory/proxmox.proxmox.yml \
    playbooks/restart-docker-stacks.yml --limit <host>

    If you pinned to a specific version, bump the tag in the compose YAML first.

The workflow also accepts workflow_dispatch so you can rerun a build without creating a new tag:

  • GitHub UI → Actions → “Publish Docker image to Harbor” → Run workflow
  • Inputs: image (e.g. github-runner), version (e.g. 0.1.0)

Useful for retrying a failed build, or seeding :latest after a Harbor reset.

Manual fallback (build + push from a workstation)

Section titled “Manual fallback (build + push from a workstation)”

When CI is unavailable, do it locally from any machine that can reach harbor.toolsera.lan. You still need Harbor credentials for the push — fetch them from BWS the same way the workflow does, or pass them inline:

Terminal window
cd docker-compose-apps/ubuntu-vm/apps/github-runner
# Option 1: pull creds from BWS (mirrors the workflow exactly)
# One-time: point bws at the EU cloud (Ansible-managed hosts already have this
# in ~/.config/bws/config via the bitwarden role; do it explicitly elsewhere).
bws config server-base https://vault.bitwarden.eu
bws run --access-token "$BWS_ACCESS_TOKEN" -- bash -c '
echo "$HARBOR_PASSWORD" | docker login harbor.toolsera.lan \
-u "$HARBOR_USERNAME" --password-stdin
docker buildx build --platform linux/amd64 --pull --push \
-t harbor.toolsera.lan/toolsera/github-runner:0.1.0 \
-t harbor.toolsera.lan/toolsera/github-runner:latest \
.
'
# Option 2: log in interactively (one-time, robot creds from Harbor UI)
docker login harbor.toolsera.lan
docker buildx build --platform linux/amd64 --pull --push \
-t harbor.toolsera.lan/toolsera/github-runner:0.1.0 \
-t harbor.toolsera.lan/toolsera/github-runner:latest \
.

Pulls from the same Harbor path don’t need any login — docker pull harbor.toolsera.lan/toolsera/<name>:<tag> works anonymously from any homelab host as long as the project is set to public.

  1. Put the Dockerfile (and any sibling files it COPYs from) in the build context directory of your choice — typically docker-compose-apps/<host>/apps/<name>/.

  2. In .github/workflows/publish-docker-image.yml, add one line to the case statement mapping the image name to the context path:

    Terminal window
    <name>) ctx="docker-compose-apps/<host>/apps/<name>" ;;
  3. In your compose YAML, reference the Harbor path:

    services:
    <name>:
    image: harbor.toolsera.lan/toolsera/<name>:latest
    build: # optional: keep for local-dev rebuilds
    context: .
    dockerfile: Dockerfile

    Keeping build: alongside image: lets docker compose build rebuild locally without going through CI; docker compose up -d (and the Ansible playbook) pulls from Harbor.

  4. Create the first version with the standard tag (git tag image/<name>-0.1.0 && git push origin image/<name>-0.1.0).

The workflow runs on the [self-hosted, homelab, deb-builder] runner — which is itself the github-runner image. If that runner is down and you’re trying to rebuild the same image (chicken-and-egg), use the manual fallback above from a workstation to seed the first image into Harbor. Future builds can then run through CI normally.

The Harbor project doesn’t exist. Create it in the Harbor UI (harbor.toolsera.lan → Projects → New Project) as toolsera with public access. See “Prerequisites” above for full one-time setup.

400 Bad Request {"error":"invalid_client"} from bws

Section titled “400 Bad Request {"error":"invalid_client"} from bws”

The bws CLI is hitting the wrong Bitwarden cloud — the default endpoint is vault.bitwarden.com (US) but the homelab token is issued by vault.bitwarden.eu. Inside the github-runner container ~/.config/bws/config doesn’t exist (only Ansible-managed hosts get it from the bitwarden role), so the workflow runs bws config server-base https://vault.bitwarden.eu before bws run to point at the right region.

unauthorized: authentication required / denied: requested access to the resource is denied on push

Section titled “unauthorized: authentication required / denied: requested access to the resource is denied on push”

The push credentials are missing or wrong. Harbor requires authentication for pushes even when the project is public. Check that HARBOR_USERNAME and HARBOR_PASSWORD are set in BWS and that the robot account still has push permission on the toolsera project (Harbor UI → Projects → toolsera → Robot Accounts).

The image hasn’t been published yet, or Harbor is unreachable from that host.

Terminal window
# From the host:
docker pull harbor.toolsera.lan/toolsera/<name>:latest
# If DNS fails:
getent hosts harbor.toolsera.lan

Add the image to the case statement in .github/workflows/publish-docker-image.yml (see step 1 of “Shipping a new version” above).

Compose prefers pulling when both image: and build: are set and the image exists in the registry. If you see it building anyway:

Terminal window
docker compose pull <service> # explicit pull
docker compose up -d <service> # then bring up

To force CI-only (never local build), drop the build: block from the compose YAML for that service.

  • .github/workflows/publish-docker-image.yml — the workflow itself
  • .github/workflows/publish-package.yml — sibling workflow that publishes .deb packages to Nexus; same bws run + self-hosted runner pattern
  • packages/README.md — Debian package publishing flow (analogous topic)