
Building and Pushing Docker Images to Harbor With a Self-Hosted GitHub Runner
/ 6 min read
Table of Contents
Overview
My project code lives on GitHub, and my homelab runs a private Harbor
registry at harbor.burakberk.dev. The problem: GitHub’s hosted runners
are public VMs — they can’t reach my homelab behind NAT, and I don’t want to
port-forward a registry to the internet. The fix is a self-hosted Actions
runner on a homelab machine: the build happens inside my network, and the
push to Harbor is a local operation.
After you follow this guide, you will have a GitHub repo that builds and
pushes Docker images to a private Harbor registry using a runner that lives
in your homelab.
Prerequisites
- A GitHub repository with Actions enabled
- A homelab machine with Docker installed
- Harbor already running, reachable as
harbor.<your-domain>(I useharbor.burakberk.dev— in a previous blog, I showed how to set up split-horizon DNS on AdGuard so this hostname resolves to the local IP inside the homelab) - A
Dockerfilein the repository
Harbor: Project & Robot Account
In the Harbor UI, create a project (I use hosted) and allow pull/push
on it. Then create a robot account scoped to that project with push
access — this is the CI credential, so a compromised workflow can’t touch
your other projects.
Save the robot’s username and token; you’ll add them as GitHub Actions
secrets: HARBOR_USER and HARBOR_TOKEN
(Settings → Secrets and variables → Actions).
The Self-Hosted Runner (Ephemeral, Docker Compose)
The runner is ephemeral: it self-registers when the container starts, serves jobs, deregisters itself when it finishes, and Docker restarts it — so every job starts on a fresh runner and no runner state survives. The registration token is fetched from the GitHub API at container start, so there is no one-time token to manage.
docker-compose.yaml:
services: github-runner: build: . restart: always # brings a fresh runner back up as soon as it stops env_file: - .env volumes: - /var/run/docker.sock:/var/run/docker.sockThe socket mount lets workflow jobs use the host Docker daemon (needed for the Harbor push below).
Dockerfile:
FROM debian:12.15-slim
ENV DEBIAN_FRONTEND=noninteractiveENV RUNNER_ALLOW_RUNASROOT=1
RUN apt-get update && apt-get install -y --no-install-recommends \ curl ca-certificates jq build-essential libssl-dev libffi-dev \ git docker.io \ && rm -rf /var/lib/apt/lists/*
WORKDIR /actions-runner
# Download the latest runner packageRUN RUNNER_VERSION=$(curl -s https://api.github.com/repos/actions/runner/releases/latest | jq -r '.tag_name' | sed 's/v//') \ && curl -o actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz -L https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz \ && tar xzf ./actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz \ && rm actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz \ && ./bin/installdependencies.sh
COPY entrypoint.sh /entrypoint.shRUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]entrypoint.sh:
#!/bin/bashset -e
# Unique name per running container (avoids name collisions)RUNNER_NAME="${RUNNER_NAME_PREFIX:-ephemeral-runner}-$(hostname)"
echo "Fetching registration token..."REG_TOKEN=$(curl -sX POST -H "Authorization: token ${ACCESS_TOKEN}" \ -H "Accept: application/vnd.github.v3+json" \ https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/actions/runners/registration-token | jq -r .token)
# --ephemeral: the runner deregisters itself when it stops./config.sh --url https://github.com/${REPO_OWNER}/${REPO_NAME} \ --token ${REG_TOKEN} \ --name ${RUNNER_NAME} \ --ephemeral \ --unattended
# exec so the container exits with run.sh and restart:always recreates itexec ./run.sh.env (never commit this — it holds a PAT):
ACCESS_TOKEN=ghp_...REPO_OWNER=<you>REPO_NAME=<repo>RUNNER_NAME_PREFIX=homelabACCESS_TOKEN is a GitHub personal access token with the Administration
permission on the repo — it is only used to fetch the short-lived
registration token.
Start it with docker compose up -d. The runner now appears in
Settings → Actions → Runners, registers itself, waits for jobs, and after
each job the container restarts into a clean state.
The Workflow
One important note before the file: I started with push, pull_request,
and tags triggers — and pull_request on a self-hosted runner is a
mistake. A PR from a fork can execute arbitrary code on your homelab
machine, with access to your HARBOR_TOKEN. So the self-hosted job runs on
push and workflow_dispatch only:
name: Build and Push to Harbor
on: push: branches: [ "main" ] tags: [ "v*.*.*" ] workflow_dispatch:
env: REGISTRY: harbor.burakberk.dev PROJECT: hosted
jobs: build-and-push: runs-on: self-hosted strategy: fail-fast: false matrix: include: - service: backend image_name: myapp-backend context: ./backend dockerfile: ./backend/Dockerfile build_args: "" - service: frontend image_name: myapp-frontend context: ./frontend dockerfile: ./frontend/Dockerfile build_args: | VITE_API_URL=https://api.example.com
steps: - name: Checkout repository uses: actions/checkout@v4
- name: Set up Docker Buildx uses: docker/setup-buildx-action@v4
- name: Log in to Harbor uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ secrets.HARBOR_USER }} password: ${{ secrets.HARBOR_TOKEN }}
- name: Extract Docker metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.PROJECT }}/${{ matrix.image_name }} tags: | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} type=ref,event=branch type=semver,pattern={{version}} type=sha,format=short
- name: Build and Push Docker Image uses: docker/build-push-action@v6 with: context: ${{ matrix.context }} file: ${{ matrix.dockerfile }} build-args: ${{ matrix.build_args }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.PROJECT }}/${{ matrix.image_name }}:buildcache cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.PROJECT }}/${{ matrix.image_name }}:buildcache,mode=maxThe matrix builds both services of the repo in one workflow — add an
entry per service, each with its own context, Dockerfile, and build args.
metadata-action tags every image by branch, semver (from the tag push),
and short SHA; latest is only produced on main.
The build cache is stored as a buildcache image in Harbor instead of the
gha cache type — it works fine on self-hosted runners, and keeping it in
the registry means the cache survives even when the runner is ephemeral.
Continuous Deployment Over the LAN
The same runner can cover the deploy step. Since it already sits on the
homelab LAN, it can SSH into the target server and run
docker compose pull && docker compose up -d there — no machine needs to be
exposed to the internet, and the only secret is an SSH private key.
- Generate a dedicated key pair (key-only auth, dedicated
deployuser on the target):
ssh-keygen -t ed25519 -f deploy_key -N ""- Copy the public key to the target server’s
~/.ssh/authorized_keys(optionally restrict it:command="cd /opt/apps/app && docker compose pull && docker compose up -d"so the key can only ever run that one command). - Add the private key to GitHub Actions secrets as
DEPLOY_KEY. - Append the deploy step to the workflow:
- name: Deploy to the homelab server env: DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} run: | mkdir -p ~/.ssh printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key ssh -i ~/.ssh/deploy_key \ -o StrictHostKeyChecking=accept-new \ "cd /opt/apps/app && docker compose pull && docker compose up -d"The SSH connection stays entirely inside the LAN; GitHub never gets a route to the homelab.
Security Notes
- A self-hosted runner means your machine executes whatever the workflow
says. Only your own repo’s
pushevents should reach it. - Keep
pull_requesttriggers off the self-hosted runner — if you want PR checks, add a second job withruns-on: ubuntu-latestthat builds without pushing. - Scope the Harbor robot account to the single project, not the whole registry.
- The runner container runs as root and holds a repo-admin PAT — keep it on a trusted machine and scope the PAT to this repo only.
Result
Every tag push produces an image in Harbor —
harbor.burakberk.dev/hosted/app:v1.2.0 — built and pushed entirely inside
the homelab. Deploying to another homelab machine is just a local
docker pull from the registry, and nothing had to be exposed to the
internet.