Illustration of a home server routing polled Git changes to isolated test containers and a separate deployment gate.
// 25 September 20264 min read

GitHub for Review, a Home Server for CI/CD

> Self-hosted CI/CD with polled refs, isolated PR tests, and exact-SHA deploys.

A CI job can start with a timer and git ls-remote. That is the shape of the small CI/CD platform I built for my personal repositories: GitHub keeps the code and pull requests, while a home server does the compute.

I wanted the useful parts of CI without tying every test run to paid hosted-runner minutes. The constraint also kept the design honest. The home server has limited CPU and memory, and pull-request code is untrusted. Every part of the pipeline needs a narrow job and a clear trust boundary.

Poll refs, then emit events

A systemd timer starts the poller for each configured repository. The poller calls git ls-remote to read remote refs. It does not clone or check out the repository during change detection.

The poller compares those refs with a small JSON state file. It writes the state atomically, then emits deterministic events when a pull-request head or the configured default branch changes. The first scan records a baseline. It emits no work. Repeating a scan with the same refs emits no duplicate events.

A new or changed pull-request head emits a pr-test event. A changed default-branch SHA emits a master-deploy event. Each actionable event carries its routing data and exact commit SHA.

{"type":"pr-test","pr":42,"action":"changed","sha":"<commit-sha>"}
{"type":"master-deploy","branch":"<default-branch>","sha":"<commit-sha>"}

Trusted local configuration supplies the job image, command, resource limits, and paths.

That makes polling a modest tradeoff. A commit waits until the next timer tick. In exchange, change detection needs no webhook endpoint, runner registration, or checkout of untrusted code.

Diagram showing Git refs flowing through the homeserver poller into isolated pull-request tests and an exact-SHA deployment gate.

Poll once, then route PR tests and trusted deployment commits separately.

Pull requests get disposable containers

Pull-request code can execute arbitrary commands, so the test route runs each job in a disposable Docker container. The source preparation step checks out the requested commit SHA into an isolated job directory. The runner mounts that source read-only and removes the container after the job.

The container has a read-only root filesystem, a temporary filesystem for /tmp, dropped Linux capabilities, and resource limits. Network access is disabled by default. The job receives no Docker socket, production mount, or deployment credential.

The event cannot choose the image, command, mount, or resource limits. Those values come from trusted local configuration. This keeps a pull request from turning its event payload into host access.

Deployment has its own gate

Tests and deployments follow different routes. A deployment consumer accepts only a master-deploy event for the configured default branch. It checks that the prepared checkout lives under the deployment root and that git rev-parse HEAD matches the event SHA exactly.

Only then does it run the deploy command from trusted local configuration. A pr-test event fails the deployment gate. The job runner also refuses a master-deploy event. This separation keeps test code away from deployment credentials and production paths.

The exact-SHA check matters. A branch name can move while a job waits. A commit SHA identifies the source version that triggered the event.

Small on purpose

This platform does not aim to recreate every hosted CI feature. It handles a focused path: poll refs, prepare the exact commit, run an isolated pull-request test, or pass a trusted commit through a separate deployment gate. Within a pipeline cycle, events run in order. Trusted job configuration sets resource limits for each PR test.

The tradeoff is ownership. I maintain the host, Docker, timers, storage, and network. Polling also adds delay compared with a push notification. The payoff is a small execution path with boundaries I can inspect, while GitHub continues to handle code and review.

That is the system I wanted: a timer to notice changes, a disposable box for untrusted tests, and a separate gate for trusted deploys.