Skip to content

Self-hosted runners ​

protoLabs CI/CD runs on self-hosted GitHub Actions runners rather than GitHub-hosted runners. This gives agents access to the Claude CLI, Docker, large RAM allocations, and persistent workspace caching that GitHub-hosted runners cannot provide.

Why self-hosted ​

RequirementGitHub-hostedSelf-hosted
Claude CLI authNot possiblePersisted on host
Docker accessLimitedFull
RAM per job7 GB maxConfigurable (8–48 GB)
Workspace cachingEphemeralPersistent between jobs
Concurrent jobsBilled per minuteUnlimited (hardware permitting)

Directory layout ​

Runners are installed as siblings under a shared directory:

/home/YOUR_USERNAME/actions-runners/
├── cleanup-hook.sh          # Post-job cleanup hook (shared by all runners)
├── runner-1/                # Each runner is an independent installation
│   ├── .runner              # Registration metadata
│   ├── .env                 # Runner env vars (including hook path)
│   └── run.sh               # Start script
├── runner-2/
├── runner-3/
└── ...

Each runner registers independently with GitHub. They appear as separate runners in the GitHub repository settings but draw from the same job queue with the self-hosted label.

Setting up runners ​

Prerequisites ​

  • Node.js v20+ (install via nvm: nvm install --lts)
  • git, gh CLI, docker installed on the host
  • build-essential (provides make, gcc, g++) — required for node-gyp native module compilation: sudo apt-get install -y build-essential python3-dev
  • A GitHub registration token from Settings → Actions → Runners → New self-hosted runner

Note: If make is missing the CI setup-project action will attempt to install build-essential automatically via apt-get, but this requires the runner user to have passwordless sudo access. It is strongly recommended to pre-install build-essential on every runner host to avoid per-job package installation overhead.

Install a single runner ​

bash
# Create directory for this runner instance
mkdir -p /home/YOUR_USERNAME/actions-runners/runner-N
cd /home/YOUR_USERNAME/actions-runners/runner-N

# Download the runner binary (check https://github.com/actions/runner/releases for latest)
curl -o actions-runner-linux-x64.tar.gz -L \
  https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-linux-x64-2.321.0.tar.gz
tar xzf actions-runner-linux-x64.tar.gz
rm actions-runner-linux-x64.tar.gz

# Register with GitHub (get token from repo Settings → Actions → Runners)
./config.sh \
  --url https://github.com/YOUR_ORG/YOUR_REPO \
  --token YOUR_REGISTRATION_TOKEN \
  --name runner-N \
  --labels self-hosted \
  --unattended

# Wire up the cleanup hook
echo 'ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/home/YOUR_USERNAME/actions-runners/cleanup-hook.sh' \
  >> /home/YOUR_USERNAME/actions-runners/runner-N/.env

Create a systemd service ​

Create /etc/systemd/system/automaker-runner-N.service:

ini
[Unit]
Description=protoLabs Studio GitHub Actions Runner N
After=network.target

[Service]
ExecStart=/home/YOUR_USERNAME/actions-runners/runner-N/run.sh
User=YOUR_USERNAME
WorkingDirectory=/home/YOUR_USERNAME/actions-runners/runner-N
KillMode=process
KillSignal=SIGTERM
TimeoutStopSec=60
Restart=always
RestartSec=5
MemoryMax=8G

[Install]
WantedBy=multi-user.target

Enable and start:

bash
sudo systemctl daemon-reload
sudo systemctl enable automaker-runner-N
sudo systemctl start automaker-runner-N

Scripted setup for multiple runners ​

When provisioning many runners at once, loop over a range. Adjust START, END, and paths for your environment:

bash
#!/bin/bash
set -euo pipefail

RUNNERS_DIR="/home/YOUR_USERNAME/actions-runners"
RUNNER_URL="https://github.com/YOUR_ORG/YOUR_REPO"
REGISTRATION_TOKEN="YOUR_TOKEN"  # from GitHub Settings → Actions → Runners
RUNNER_VERSION="2.321.0"
START=9
END=18

mkdir -p "$RUNNERS_DIR"

for N in $(seq "$START" "$END"); do
  DIR="$RUNNERS_DIR/runner-$N"
  mkdir -p "$DIR"

  # Download binary
  curl -o "$DIR/runner.tar.gz" -L \
    "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"
  tar xzf "$DIR/runner.tar.gz" -C "$DIR"
  rm "$DIR/runner.tar.gz"

  # Register
  "$DIR/config.sh" \
    --url "$RUNNER_URL" \
    --token "$REGISTRATION_TOKEN" \
    --name "runner-$N" \
    --labels self-hosted \
    --unattended

  # Wire cleanup hook
  echo "ACTIONS_RUNNER_HOOK_JOB_COMPLETED=${RUNNERS_DIR}/cleanup-hook.sh" >> "$DIR/.env"

  # Create systemd unit
  sudo tee "/etc/systemd/system/automaker-runner-${N}.service" > /dev/null <<EOF
[Unit]
Description=protoLabs Studio GitHub Actions Runner ${N}
After=network.target

[Service]
ExecStart=${DIR}/run.sh
User=${USER}
WorkingDirectory=${DIR}
KillMode=process
KillSignal=SIGTERM
TimeoutStopSec=60
Restart=always
RestartSec=5
MemoryMax=8G

[Install]
WantedBy=multi-user.target
EOF

  sudo systemctl daemon-reload
  sudo systemctl enable "automaker-runner-${N}"
  sudo systemctl start "automaker-runner-${N}"

  echo "Runner $N started"
done

Note: Each runner registration uses the same token. Tokens expire after one hour — generate a fresh one if registrations start failing.

Post-job cleanup hook ​

Without cleanup, build artifacts accumulate across jobs and slowly fill the disk. The hook runs after every completed job:

bash
#!/bin/bash
# /home/YOUR_USERNAME/actions-runners/cleanup-hook.sh

WORKSPACE="${GITHUB_WORKSPACE:-}"
if [ -z "$WORKSPACE" ] || [ ! -d "$WORKSPACE" ]; then
  exit 0
fi

# Remove root and app-level node_modules
rm -rf "$WORKSPACE/node_modules" 2>/dev/null || true
rm -rf "$WORKSPACE/apps/ui/node_modules" 2>/dev/null || true
rm -rf "$WORKSPACE/apps/server/node_modules" 2>/dev/null || true

# Remove build outputs
rm -rf "$WORKSPACE/apps/ui/dist" 2>/dev/null || true
rm -rf "$WORKSPACE/apps/server/dist" 2>/dev/null || true

# Remove shared package build outputs (libs/*/dist accumulates across jobs)
find "$WORKSPACE/libs" -maxdepth 2 -name "dist" -type d -exec rm -rf {} + 2>/dev/null || true

# Keep .git for faster incremental fetches on next checkout

Each runner's .env file activates this hook:

ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/home/YOUR_USERNAME/actions-runners/cleanup-hook.sh

Concurrency and memory limits ​

Per-runner memory cap ​

Each runner service sets MemoryMax=8G. This prevents a runaway build job from consuming all available RAM and destabilising other runners on the same host.

Concurrent agent limit ​

protoLabs Studio agents (Claude Sonnet, Opus) allocate significant RAM per instance. Rough allocations:

ModelRAM per agentMax safe concurrent
Haiku~2 GB20
Sonnet~4 GB10
Opus~6 GB8

Critical threshold: do not exceed 13 concurrent agents. Above this the server process will crash due to V8 heap exhaustion. This is enforced in the server's maxConcurrency setting (default: 6).

Sizing guidance ​

Host RAMRecommended runnersNotes
32 GB4Leaves room for OS + Docker stack
64 GB6–8Comfortable for mixed Sonnet/Haiku load
128 GB10–12Full concurrent agent capacity

Management ​

Check all runner services ​

bash
# Status of all runners at once
systemctl status 'automaker-runner-*'

# Follow logs for a specific runner
journalctl -u automaker-runner-3 -f

# Restart a single runner
sudo systemctl restart automaker-runner-3

Verify GitHub registration ​

Check that all runners show as Online in GitHub → Settings → Actions → Runners. A runner showing as Offline usually means:

  1. The systemd service failed to start — check systemctl status automaker-runner-N
  2. The service unit points to a missing run.sh — verify ExecStart path
  3. The registration was removed from GitHub — re-register with a fresh token

Remove a runner ​

bash
# Stop and disable the service
sudo systemctl stop automaker-runner-N
sudo systemctl disable automaker-runner-N
sudo rm /etc/systemd/system/automaker-runner-N.service
sudo systemctl daemon-reload

# Remove GitHub registration
cd /home/YOUR_USERNAME/actions-runners/runner-N
./config.sh remove --token YOUR_REMOVAL_TOKEN

Next steps ​

Built by protoLabs — Open source on GitHub