skip to content
Burak Berk
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 use harbor.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 Dockerfile in 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.sock

The 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=noninteractive
ENV 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 package
RUN 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.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

entrypoint.sh:

#!/bin/bash
set -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 it
exec ./run.sh

.env (never commit this — it holds a PAT):

Terminal window
ACCESS_TOKEN=ghp_...
REPO_OWNER=<you>
REPO_NAME=<repo>
RUNNER_NAME_PREFIX=homelab

ACCESS_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=max

The 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.

  1. Generate a dedicated key pair (key-only auth, dedicated deploy user on the target):
Terminal window
ssh-keygen -t ed25519 -f deploy_key -N ""
  1. 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).
  2. Add the private key to GitHub Actions secrets as DEPLOY_KEY.
  3. 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 push events should reach it.
  • Keep pull_request triggers off the self-hosted runner — if you want PR checks, add a second job with runs-on: ubuntu-latest that 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.