A production-ready GitHub Actions self-hosted runner for running Roku hardware tests with network isolation and security hardening.
- App-key isolation via sidecar registrar: the GitHub App private key is mounted only into a one-shot registrar container that mints a short-lived runner registration token, then idles. The runner container has no App credentials in its env, so a malicious
pull_request_targetPR can't exfiltrate the key. - Network Isolation: Runner can only access GitHub (HTTPS), npm registries, and your specific Roku device
- Security Hardening: Runs with dropped capabilities, no-new-privileges, and resource limits
- Ephemeral Mode: Fresh container for every job execution
- Health Checks: Monitors runner process and auto-restarts if unhealthy
- Log Rotation: Prevents disk space issues
- Easy Deployment: One-command installation with systemd integration
- Linux server with Docker and Docker Compose installed
- GitHub App with appropriate permissions
- Roku device on the same network for testing
- Minimum specs: 1 CPU, 3GB RAM
git clone https://github.com/jellyrock/github-runner.git
cd github-runner
cp .env.example .env- Go to your organization settings:
https://github.com/organizations/jellyrock/settings/apps - Click New GitHub App
- Configure:
- GitHub App name:
jellyrock-runner(or any unique name) - Homepage URL:
https://github.com/jellyrock - Webhook: Disable (uncheck "Active")
- GitHub App name:
- Set Permissions (minimum required for the sidecar registrar pattern):
- Actions: Read & Write — required to mint runner registration tokens
- Metadata: Read-only — auto-granted
- (Add
Contents: Read & Writeand/orPull requests: Read & Writeonly if your App also does auto-commits or auto-PRs. The runner itself does not need them.) - Do NOT grant
AdministrationorWorkflows: write— the runner registration flow doesn't need them, and they massively widen blast radius if the key leaks.
- Click Create GitHub App
- Scroll down to Private keys and click Generate a private key
- Save the downloaded
.pemfile securely - Click Install App (left sidebar)
- Select your repository and click Install
- Note the App ID from the URL or app settings page
- Note the Installation ID from the URL after installing (format:
/installations/INSTALL_ID)
The App private key never lives in .env or in the runner container's env — only the registrar sidecar reads it, from a host path with root:0400 perms.
sudo mkdir -p /etc/github-app
sudo chown root:root /etc/github-app
sudo chmod 0700 /etc/github-app
# Paste the private key downloaded in step 2:
sudo tee /etc/github-app/key.pem < /path/to/your-app-private-key.pem
sudo chmod 0400 /etc/github-app/key.pem
# Numeric App ID (single line)
echo "123456" | sudo tee /etc/github-app/app-id
sudo chmod 0400 /etc/github-app/app-id
# Numeric Installation ID (single line)
echo "78901234" | sudo tee /etc/github-app/install-id
sudo chmod 0400 /etc/github-app/install-id
# "owner/repo" format
echo "jellyrock/jellyrock" | sudo tee /etc/github-app/repo-url
sudo chmod 0400 /etc/github-app/repo-urlEdit .env with the (non-secret) runner config:
# Required: Roku device IP (no default - must be set)
ROKU_DEVICE_IP=192.168.1.200
# Optional: Customize runner
RUNNER_NAME=roku-runner-01
RUNNER_LABELS=self-hosted,roku,roku-device
TIMEZONE=America/New_YorkROKU_DEVICE_IP is required. None of the GitHub App credentials should ever be in .env.
Default installation (to /opt/github-runner):
sudo ./install.sh
sudo systemctl enable --now github-runnerCustom installation path:
# Install to a custom directory
sudo ./install.sh /var/lib/github-runner
# Then start
sudo systemctl enable --now github-runnerThe install script will:
- Copy all necessary files to the specified directory (including
mint-runner-token.shandrunner-entrypoint.sh) - Create a systemd service with the correct working directory
- Set up log rotation and health checks
# Check service status
sudo systemctl status github-runner
# View runner logs
docker logs -f roku-runner
# Check GitHub (should show runner as online)
# https://github.com/jellyrock/jellyrock/settings/actions/runners┌────────────────────────────────────────────────────────────────────┐
│ Host System (Linux) │
│ │
│ /etc/github-app/ ← App key + IDs (root:0400, host-only) │
│ │ │
│ │ bind-mount RO │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ roku-runner-registrar │ mints registration token, │
│ │ (alpine, one-shot) │ writes /shared/runner-token, idles │
│ └────────────┬────────────┘ │
│ │ (shared volume) │
│ ▼ │
│ ┌────────────────────────┐ ┌──────────────────────┐ │
│ │ roku-runner │ │ iptables-sidecar │ │
│ │ (no App env vars) │──│ (network filtering) │ │
│ │ reads RUNNER_TOKEN │ │ - NET_ADMIN cap │ │
│ │ from shared volume │ │ - blocks local LAN │ │
│ └────────────────────────┘ └──────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ GitHub │ │ npm/CDNs │ │ Roku Device │
│ (HTTPS) │ │ (HTTPS) │ │ (.env IP) │
└─────────────┘ └─────────────┘ └─────────────┘
Key isolation property: a malicious pull_request_target PR running inside roku-runner can read its own env, its filesystem, and the (already-consumed, ~1-hour-expiring) registration token in /shared/runner-token. It cannot reach /etc/github-app/key.pem — that path is only mounted into the registrar, which exits after writing the token.
- Egress Allowed: HTTP/HTTPS to any destination (required for npm, apt, GitHub)
- Local Network: Restricted to only the Roku device IP from
.env - Isolation: Uses iptables sidecar container with NET_ADMIN capability
- No New Privileges: Prevents privilege escalation
- Capability Dropping: All capabilities dropped except required ones (SETUID, SETGID, CHOWN, DAC_OVERRIDE, FOWNER)
- Read-Only Root: tmpfs mounts for /tmp and /home/runner
- Resource Limits: 1 CPU, 3GB RAM per runner
- Ephemeral: Fresh container for every job (no persistence between runs)
| Variable | Required | Default | Description |
|---|---|---|---|
ROKU_DEVICE_IP |
Yes | None | IP address of Roku test device (REQUIRED) |
RUNNER_NAME |
No | roku-runner-01 |
Runner display name |
RUNNER_LABELS |
No | self-hosted,roku,roku-device |
Labels for job matching |
TIMEZONE |
No | America/New_York |
Container timezone |
GitHub App credentials are not environment variables — they live as files in /etc/github-app/ on the host (see step 3). The runner container has no access to them; only the registrar sidecar does.
| Host file | Required | Description |
|---|---|---|
/etc/github-app/key.pem |
Yes | App private key (RSA PEM), 0400 root:root |
/etc/github-app/app-id |
Yes | Numeric App ID, single line |
/etc/github-app/install-id |
Yes | Numeric Installation ID, single line |
/etc/github-app/repo-url |
Yes | owner/repo (e.g. jellyrock/jellyrock) |
- CPU: 1 core
- Memory: 3GB
- Logs: 10MB per file, max 3 files (rotated)
# Start
sudo systemctl start github-runner
# Stop
sudo systemctl stop github-runner
# Restart
sudo systemctl restart github-runner
# View logs
sudo journalctl -u github-runner -f
docker logs -f roku-runnerThe runner image is pinned to a digest (not :latest) and bumped by
Renovate. To update:
-
Wait for the Renovate PR (titled
chore(deps): pin myoung34/github-runner …) -
Review and merge
-
Pull the new digest on the host and restart:
cd /opt/github-runner # or your INSTALL_DIR sudo docker compose pull sudo systemctl restart github-runner
The chore(deps) digest PRs are auto-merged on green CI (see renovate.json).
For emergency updates outside the Renovate cadence (e.g. GitHub deprecates a
runner version sooner than expected), edit the digest in docker-compose.yml
directly and run the same compose pull + systemctl restart cycle.
If the runner host fails:
- Setup new hardware with Docker installed
- Clone this repository:
git clone https://github.com/jellyrock/github-runner.git - Restore
/etc/github-app/from a secure backup (or re-create per step 3 of Quick Start using your existing App's.pemand IDs) - Restore
.envfrom backup (contains only Roku IP + runner labels + optionalHEALTHCHECKS_URL— no secrets) - Run install.sh:
sudo ./install.sh(add--service-nameif not using the default) - Start service:
sudo systemctl enable --now github-runner(or your chosen service name)
The runner will automatically register with GitHub using the same name.
The runner has three layers of failure detection, each catching a different class of problem:
| Layer | Catches | Configured by |
|---|---|---|
Docker healthcheck (ps aux | grep Runner.Listener) |
Container alive but listener process dead | docker-compose.yml (always on) |
systemd StartLimitBurst=5/300s |
Repeated container crashes (deprecated binary, bad config) | Unit file emitted by install.sh |
| Healthchecks.io push heartbeat | "Runner offline" from GitHub's perspective — covers all of the above plus host-level issues (kernel panic, docker dead, network down) | HEALTHCHECKS_URL in .env |
-
Create a check at https://healthchecks.io/ (or your self-hosted HC instance)
-
Configure: period =
1 minute, grace =5 minutes. The grace covers brief gaps between ephemeral job cycles. -
Add HC's Gotify (or email, Slack, etc.) integration to the check
-
Copy the check's ping URL into
.env:HEALTHCHECKS_URL=https://hc-ping.com/<your-check-uuid>
-
sudo systemctl restart <service-name>to pick up the change
The runner pings <URL>/start at container boot and <URL> every 60 s while it's alive. A deprecated runner that crashes within 10 s of startup never gets to the periodic ping, so HC.io alerts after the 5-min grace.
Cause: GitHub deprecates older runner binaries every 1–3 months. The myoung34/github-runner image bundles a specific binary version — once GitHub deprecates it, the runner fails immediately on every start.
Fix:
cd /opt/github-runner # or your INSTALL_DIR
sudo docker compose pull
sudo systemctl reset-failed <service-name> # clear StartLimitBurst counter
sudo systemctl restart <service-name>
docker logs roku-runner | grep "Current runner version"Prevention: the image is digest-pinned and Renovate raises PRs that auto-merge on green CI (see renovate.json). Make sure the Renovate GitHub App is installed on this repository and that CI is green for digest PRs.
# Check service status
sudo systemctl status <service-name>
# Watch the live runner output
docker logs -f roku-runner
# Confirm registration token was minted
docker logs roku-runner-registrar | tailThe unit gives up after 5 failed starts in 5 minutes to avoid the multi-thousand-restart pathology. To clear it after fixing the underlying cause:
sudo systemctl reset-failed <service-name>
sudo systemctl start <service-name>The runner needs HTTPS access to the npm registry. Check iptables:
docker exec roku-iptables iptables -L OUTPUT -n | grep 443Verify the IP in .env:
# From the runner container
docker exec roku-runner ping $ROKU_DEVICE_IPThe runner uses root user by design (required by myoung34 image). This is normal.
install.sh defaults to /opt/github-runner + github-runner.service, which assumes a standalone host. If you're co-locating the runner with other Compose projects (BATCAVE's /home/alfred/docker/compose/cicd/ is the production example), use --service-name so the systemd unit name doesn't collide with any host-wide github-runner convention:
sudo ./install.sh /home/alfred/docker/compose/cicd --service-name roku-runnerThis emits /etc/systemd/system/roku-runner.service with --project-name roku-runner baked into every docker compose invocation. That gives the runner its own Compose project namespace, fully isolated from any sibling project that might do docker compose up --remove-orphans in a parent directory.
Sibling-project safety: if a peer script does bulk compose up --remove-orphans from a parent directory (the BATCAVE start-docker-containers.sh pattern), make sure it explicitly excludes the runner's compose file — e.g. grep -v '^./cicd/'. The --project-name isolation means --remove-orphans can't reach across projects, but it's still safest to exclude the file entirely so the runner is never (re)started outside its systemd unit.
- Make changes to configuration files
- Restart the service:
sudo systemctl restart github-runner - Trigger a test workflow in your repository
This runner is specifically designed for Roku hardware testing. To modify for other use cases:
- Update
REPO_URLin docker-compose.yml - Adjust
RUNNER_LABELSfor your workflow matching - Modify resource limits as needed
For issues or questions:
- Check logs:
docker logs roku-runner - Verify configuration in
.env - Test network connectivity from container
- Open an issue in this repository