Guide

A Self-Hosted CI Runner

Part 2 of 5By Amith Kumar9 min read
Part 2 of 5CI/CD on a Single VM: A Hands-On Guide
  1. 1Push-to-Deploy with a Git Hook
  2. 2A Self-Hosted CI Runner
  3. 3A Build, Test and Deploy Pipeline
  4. 4Secrets in CI
  5. 5Zero-Downtime Release and Rollback
Verified 22 Jul 2026 · Ubuntu 24.04 LTS

Run a self-hosted runner on one VM

A git hook fires when someone pushes. A self-hosted runner adds the other trigger a build system needs: an on-demand HTTP call, the shape a forge like Gitea or GitHub uses when it POSTs a webhook instead of pushing over SSH. This chapter installs webhook, a small runner from the Ubuntu 24.04 archive, registers one build hook, and fires a real build and deploy with a single authenticated POST. Every command ran on a live VPS, and the outputs shown are what it returned.

Key takeaways
  • webhook by adnanh is a single Go binary that runs a command when it receives a matching, authenticated HTTP request; it is a build runner, not a CI dashboard.
  • Bind the runner to 127.0.0.1 so only a local reverse proxy or an SSH tunnel can reach it, never the open internet.
  • Gate every hook with a shared secret in a header, so a request without the token is rejected before any command runs.
  • Run the runner as an unprivileged user with a narrow sudoers rule for the one restart it needs.

Let me be honest about scope. A full product like Woodpecker CI or Gitea Actions gives you a web UI, pipelines as config, and a stored run history. Both also need a separate forge server running somewhere.

On one VM, that is more moving parts than the job needs. webhook is the runner that installs cleanly from apt and does the real work: receive a trigger, check a secret, run a script, and return its output. Call it what it is, a webhook-driven build runner, and it fits a single box.

Prerequisites

Before you start
  • The git-hook deploy from part one, with the pipeclip app under /srv/pipeclip and the pipeci deploy user in place.
  • A build script at /srv/pipeclip/bin/pipeline.sh that runs your build, test, and deploy stages and exits non-zero on failure.
  • Ubuntu 24.04 with apt access, plus sudo to add a systemd unit.
  • curl on hand, since we prove each trigger by the response it returns.

Install the runner

webhook comes straight from the Ubuntu archive, so there is no third-party repo to add and no Go toolchain to set up. The install pulls a single binary to /usr/bin/webhook.

sudo apt install -y webhook
webhook -version

Reading the version back prints webhook version 2.8.0. That one file is the whole runner. No daemon of its own, no database, no agent to register against a control plane.

Define the build hook

A hook is one JSON object: what to run, what to pass it, and the rule that must pass first. This config lives at /etc/pipeclip/hooks.json and defines a single hook called deploy-pipeclip.

[
  {
    "id": "deploy-pipeclip",
    "execute-command": "/srv/pipeclip/bin/pipeline.sh",
    "command-working-directory": "/srv/pipeclip",
    "pass-arguments-to-command": [
      { "source": "string", "name": "main" }
    ],
    "include-command-output-in-response": true,
    "trigger-rule-mismatch-http-response-code": 403,
    "trigger-rule": {
      "match": {
        "type": "value",
        "value": "s3cr3t-webhook-token",
        "parameter": { "source": "header", "name": "X-Pipeclip-Token" }
      }
    }
  }
]

The hook runs pipeline.sh with the single argument main, from the working directory /srv/pipeclip. Setting include-command-output-in-response to true echoes the script's stdout back to the caller, so the HTTP response carries the build log.

The trigger-rule is the gate. The header X-Pipeclip-Token must equal the shared value, and on a mismatch webhook returns 403 and runs nothing. No token, no build.

Run it as a locked-down service

A systemd unit keeps the runner up and pins how it runs. This one runs webhook as the unprivileged pipeci user, never root, and binds it to loopback.

[Unit]
Description=pipeclip CI webhook runner (adnanh/webhook)
After=network.target
[Service]
User=pipeci
Group=pipeci
ExecStart=/usr/bin/webhook -hooks /etc/pipeclip/hooks.json -ip 127.0.0.1 -port 9099 -verbose
Restart=on-failure
[Install]
WantedBy=multi-user.target

Port 9000 is webhook's default, but it was already taken on this box, so I picked 9099, a free loopback port. Any unused high port works. The -verbose flag makes the runner log each request, which we read at the end. Restart=on-failure brings the process back if it dies. Enable and start it.

sudo systemctl daemon-reload
sudo systemctl enable --now pipeclip-webhook

Confirm it is listening on loopback only

Two checks tell you the runner is up and not exposed. Ask systemd whether the unit is active, then ask the kernel what it is listening on.

systemctl is-active pipeclip-webhook
sudo ss -tlnp | grep 9099
The webhook runner reporting version 2.8.0, its systemd service active, and ss showing it listening on 127.0.0.1 port 9099 only, not on the public interface

The first command returns active. The ss line shows the listener on 127.0.0.1:9099 and nothing on 0.0.0.0, so the runner is reachable only from the host itself. A request from the network cannot touch it; a local reverse proxy or an SSH tunnel is the only route in.

Checkpoint

You should see active from the first command and a listener on 127.0.0.1:9099 with no 0.0.0.0 line from the second. If ss shows the runner on the public interface, stop and re-check the -ip 127.0.0.1 flag in the unit before you send it a single request.

Fire the build with the token

Now send the trigger a forge would send, by hand. Include the header the config checks, and POST to the hook URL.

curl -X POST -H 'X-Pipeclip-Token: s3cr3t-webhook-token' -d '{"ref":"main"}' http://127.0.0.1:9099/hooks/deploy-pipeclip
An authenticated POST to the runner triggering a real build, the returned output showing the build stamp, three tests passing, a post-deploy health check of 200, and the deployed version

The token matches, so the hook triggers and the whole pipeline runs. Because the config echoes command output, the response carries the full log. The build stage prints the short commit 3a62be8, the test stage prints Ran 3 tests in 0.03s and OK, and the deploy stage prints post-deploy health: 200. The last line reads deployed 1.2.0 build 3a62be8. One authenticated POST ran build, test, and deploy end to end.

Reject a request with no token

The gate is only real if a missing token stops the build. Send the same POST with no header, then read the runner's log.

curl -X POST -d '{"ref":"main"}' http://127.0.0.1:9099/hooks/deploy-pipeclip
journalctl -u pipeclip-webhook
The runner journal showing a matched and triggered hook executing the pipeline and finishing 200, and a separate request with no token rejected with a 403 because the trigger rule was not satisfied

The unauthenticated POST returns HTTP 403, and nothing runs. The log explains why: the hook matched the URL, but the trigger rule was not satisfied, so no command executed.

journalctl shows the framing for both calls. The accepted one logs incoming HTTP POST request from 127.0.0.1, then deploy-pipeclip got matched, deploy-pipeclip hook triggered successfully, an executing /srv/pipeclip/bin/pipeline.sh line carrying the main argument, the command output, finished handling deploy-pipeclip, and a 200 for POST /hooks/deploy-pipeclip. The rejected one logs a 403 for the same path, with no execute line at all. Nothing ran, which is the point of the gate.

Three ways this stays contained
  • The runner binds to 127.0.0.1, so only a local reverse proxy or an SSH tunnel reaches it, never a random host on the internet.
  • Every hook is gated by a shared secret in a header, and a request without it is refused with a 403 before any command runs.
  • The unit runs as pipeci, not root, with a narrow sudoers rule for only the one service restart the pipeline needs.

Going further: give it a public door and a real secret

The runner is deliberately private, so to let a forge reach it you put nginx in front rather than exposing port 9099. A server block on your real hostname terminates TLS and proxies to 127.0.0.1:9099, which means the certificate and the public port belong to nginx while the runner stays a loopback backend. From the runner's point of view nothing changes: it still receives a POST, checks the header, and runs the hook.

The shared token in this chapter is a stand-in for the real thing. A forge like Gitea signs each webhook body with a secret and sends an HMAC in a header, and webhook can verify that signature instead of a plain value, which is stronger because the token itself never travels on the wire. The next part gives that gate teeth: a pipeline where the build and the tests decide whether the deploy stage runs at all, so a trigger with a valid token still cannot ship a broken commit.

Frequently asked questions

Is webhook a full CI system?

No, and it does not pretend to be. It has no web dashboard, no stored run history, and no pipeline-as-config graph. It receives an authenticated HTTP request, checks a rule, runs one command, and returns its output. Products like Woodpecker or Gitea Actions add those extras, but each needs a separate forge server to run. On one VM, webhook does the runner's real job with far less to operate and patch.

How would a forge like Gitea call this?

A forge sends a POST to the hook URL on every push or release, with a secret in a header or a signed body. Point Gitea's webhook at https://pipeclip.example.com/hooks/deploy-pipeclip through your reverse proxy, and set the same secret this config checks. From the runner's side, a forge call and a manual curl are identical: a matching POST that carries the right token. Nothing about the hook changes when a machine sends it instead of you.

Why bind to 127.0.0.1 rather than expose 9099 directly?

The runner has no TLS and only a header check, so it should not face the internet on its own. Binding to loopback means only processes on the host reach it. Put nginx in front to terminate TLS and forward to 127.0.0.1:9099, or reach it over an SSH tunnel for a manual trigger. The public port and the certificate belong to the proxy, and the runner stays a private backend.

Does the pipeline run as root?

No. The unit runs webhook as pipeci, an unprivileged user. If a stage needs to restart the app service, grant that single action through a narrow sudoers rule for the exact systemctl command, rather than running the whole runner as root. Then a leaked token triggers a scripted deploy of your own pipeline, not arbitrary commands with full privileges. Keep the blast radius the size of one script.

What you have, and what comes next

You have a self-hosted runner on Ubuntu 24.04 that fires a real build, test, and deploy from one authenticated POST. It binds to loopback, checks a shared secret, and runs as an unprivileged user. You watched a valid token run the pipeline and a missing token earn a 403.

The pipeline still deploys straight over the running app, with no safety net if the new version is broken. The next chapter puts a health check between build and release, so a deploy that fails its own check is rolled back before it serves a single bad request.


Deploy from a slice

Run your own CI/CD on a slice

Automated deploys without a CI bill need one always-on box. Spin up a slice, set up push-to-deploy with the zero-downtime releases and rollback from this guide.

Get early access

This guide is provided for educational purposes and offered as is, without warranty of any kind. The commands change the configuration of a server you control, and some can lock you out if run out of order. Test on a non-production machine, keep a second session open, and take a snapshot or backup before you begin. You are responsible for changes made to your own systems; ServerCake accepts no liability for any loss, downtime, lockout, or damage arising from following this guide or running the accompanying script.

Was this guide helpful?
Share

Spotted something out of date or have a question?

Built by the ServerCake team

Cloud that speaks India.

ServerCake is India's KYC-verified cloud: VMs and managed databases priced in ₹, billed with GST, on India-resident infrastructure. Reserve your spot for early access.

Reserve your spot