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.
How it works
Section titled “How it works”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.
Prerequisites
Section titled “Prerequisites”One-time setup before the first workflow run:
- Harbor project — create a project named
toolserain the Harbor UI (harbor.toolsera.lan→ Projects → New Project). Set access level to Public so pulls from homelab hosts don’t need credentials. - Robot account — under the
toolseraproject, create a Harbor robot withpush+pullon repository. Note the auto-generated robot name (e.g.robot$toolsera+publisher) and secret. - BWS secrets — add to the BWS project this repo uses:
HARBOR_USERNAME= the full robot account name (withrobot$prefix)HARBOR_PASSWORD= the robot secret
Shipping a new version
Section titled “Shipping a new version”-
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" ingithub-runner) ctx="docker-compose-apps/ubuntu-vm/apps/github-runner" ;;# add new images hereesacIf you’re shipping a brand-new image, add a line. Otherwise skip to step 2.
-
Tag and push:
Terminal window git tag image/github-runner-0.1.0git push origin image/github-runner-0.1.0Tag format is
image/<name>-<version>. Use semantic versioning for<version>. -
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)
-
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.
Manual trigger (no tag)
Section titled “Manual trigger (no tag)”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:
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.eubws 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.landocker 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.
Adding a new image to the system
Section titled “Adding a new image to the system”-
Put the
Dockerfile(and any sibling files it COPYs from) in the build context directory of your choice — typicallydocker-compose-apps/<host>/apps/<name>/. -
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>" ;; -
In your compose YAML, reference the Harbor path:
services:<name>:image: harbor.toolsera.lan/toolsera/<name>:latestbuild: # optional: keep for local-dev rebuildscontext: .dockerfile: DockerfileKeeping
build:alongsideimage:letsdocker compose buildrebuild locally without going through CI;docker compose up -d(and the Ansible playbook) pulls from Harbor. -
Create the first version with the standard tag (
git tag image/<name>-0.1.0 && git push origin image/<name>-0.1.0).
Bootstrap notes
Section titled “Bootstrap notes”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.
Troubleshooting
Section titled “Troubleshooting”404 page not found on push
Section titled “404 page not found on push”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).
pull access denied on a host
Section titled “pull access denied on a host”The image hasn’t been published yet, or Harbor is unreachable from that host.
# From the host:docker pull harbor.toolsera.lan/toolsera/<name>:latest
# If DNS fails:getent hosts harbor.toolsera.lanWorkflow fails with “unknown image”
Section titled “Workflow fails with “unknown image””Add the image to the case statement in .github/workflows/publish-docker-image.yml (see
step 1 of “Shipping a new version” above).
Compose tries to build instead of pulling
Section titled “Compose tries to build instead of pulling”Compose prefers pulling when both image: and build: are set and the image exists in
the registry. If you see it building anyway:
docker compose pull <service> # explicit pulldocker compose up -d <service> # then bring upTo force CI-only (never local build), drop the build: block from the compose YAML for
that service.
Related
Section titled “Related”.github/workflows/publish-docker-image.yml— the workflow itself.github/workflows/publish-package.yml— sibling workflow that publishes.debpackages to Nexus; samebws run+ self-hosted runner patternpackages/BUILDING.md— Debian package publishing flow (analogous topic)