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/README.md— Debian package publishing flow (analogous topic)