# Running the device server as a service

Source: https://codeherder.com/docs/running-the-device-server/

How to run ch device-server as a persistent background service with systemd (Linux), launchd (macOS), or Docker so it survives terminal logout and reboot.

Running `ch device-server` in a terminal works for a quick test, but the device goes Offline as soon as that terminal closes. Configure it as a background service and the device server starts automatically at login, restarts on crash, and persists across reboots — so agents can always pick up work without any manual action. If you’d rather the device clean up and take itself out of the fleet once it runs out of work, see [Devices that clean up after themselves](https://codeherder.com/docs/ephemeral-devices/) instead.

## Before you start

One thing must be in place before setting up the service: **a CLI credential.** `ch device-server` authenticates to CodeHerder at startup using either `CH_TOKEN` (an API key) or a named profile. Mint an API key from **Set up → Members** or with `ch member keys create` — see [Minting API keys](https://codeherder.com/docs/members-and-teams/#minting-api-keys). See [Credentials and profiles](https://codeherder.com/docs/credentials/) for how to store it in a profile.

**Register the device interactively once, before installing the service.** Run `ch device-server` yourself from a terminal first and accept the one-time registration prompt — see [How do I add a device?](https://codeherder.com/docs/adding-a-device/) for what that prompt looks like and what it does. Skip this and install the service directly instead: the service starts and keeps running, but with no terminal to answer the prompt it never registers, so the device never appears in your workspace and the service just idles indefinitely with no error to flag the problem. Once `ch device list` confirms the device is **Online**, the service setup below can take over — every subsequent start just reads the saved token and reconnects, prompt-free. See [Quickstart](https://codeherder.com/docs/quickstart/) for the full walkthrough from a fresh account.

**A `ch start` registration counts too.** If you’ve already run `ch start` on this machine, it registered a device — as a personal one, disabled by default (see [Personal devices](https://codeherder.com/docs/devices/#personal-devices)). Installing the service on that same registration brings it Online, but the engine still won’t hand it other people’s tasks until you promote it:

```
ch device enable <deviceId>
```

## What the device server needs at startup

`ch device-server` requires two things before it can connect:

**1. A CLI credential** — used by the metrics reporter. Without it the server exits immediately with an error. Supply it via the `CH_TOKEN` environment variable or the `CH_PROFILE` variable pointing to a named credential profile.

**2. The device token file** — `~/.codeherder/device.token` by default. This file is written the first time you run `ch device-server` interactively and accept the registration prompt (see [How do I add a device?](https://codeherder.com/docs/adding-a-device/)) — a non-interactive run never writes it, which is why registering first, before installing the service, matters (see above). The server must run as the user who owns this file so the `~` path resolves correctly. If you need to run the server as a different user, pass `--token-file <absolute-path>` to point at the file explicitly. See [Device tokens](https://codeherder.com/docs/device-tokens/) for what this token is and how to rotate it if it’s ever compromised.

## Linux: systemd user service

A systemd **user** service runs as your account, starts at login, and automatically restarts the server if it exits unexpectedly.

### 1. Create the unit file

```
mkdir -p ~/.config/systemd/user
```

Write the following to `~/.config/systemd/user/codeherder-device-server.service`. Replace the `ExecStart` path with the location of your `ch` binary (run `which ch` to find it) and fill in your credentials:

```
[Unit]
Description=CodeHerder device server
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=%h/.local/bin/ch device-server
Restart=on-failure
RestartSec=5
Environment="CH_TOKEN=<your-api-key>"
Environment="CH_WORKSPACE_ID=<your-workspace-id>"

[Install]
WantedBy=default.target
```

`%h` is a systemd specifier that expands to your home directory. Adjust the binary path if you installed `ch` elsewhere.

> **Using a credential profile instead:** To keep credentials out of the unit file, create a profile at `~/.codeherder/main.env` (see [Credentials and profiles](https://codeherder.com/docs/credentials/)) and replace the two `Environment=` lines above with:
>
> ```
> Environment="CH_PROFILE=main"
> ```

### 2. Enable and start the service

```
systemctl --user daemon-reload
systemctl --user enable --now codeherder-device-server.service
```

`enable` registers the service for future logins; `--now` starts it immediately.

### 3. Start at boot (not just at login)

By default, user services start only when you log in interactively. To start the device server at boot — before any login — enable lingering for your account:

```
loginctl enable-linger $USER
```

This keeps your user session alive at boot so your user services start as soon as the machine is ready.

### Useful commands

```
# Check service status
systemctl --user status codeherder-device-server

# Follow live logs
journalctl --user -u codeherder-device-server -f

# Restart after a configuration change
systemctl --user restart codeherder-device-server

# Stop the service
systemctl --user stop codeherder-device-server
```

## macOS: launchd LaunchAgent

A launchd **LaunchAgent** runs as your account, starts at login, and automatically restarts the server if it exits.

### 1. Create the plist

Write the following to `~/Library/LaunchAgents/com.codeherder.device-server.plist`. Replace the binary path with the absolute path to your `ch` binary (run `which ch` to find it) and fill in your credentials:

```
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
    "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.codeherder.device-server</string>

    <key>ProgramArguments</key>
    <array>
        <string>/Users/youruser/.local/bin/ch</string>
        <string>device-server</string>
    </array>

    <key>EnvironmentVariables</key>
    <dict>
        <key>CH_TOKEN</key>
        <string>your-api-key</string>
        <key>CH_WORKSPACE_ID</key>
        <string>your-workspace-id</string>
    </dict>

    <key>RunAtLoad</key>
    <true/>

    <key>KeepAlive</key>
    <true/>

    <key>StandardOutPath</key>
    <string>/tmp/codeherder-device-server.log</string>

    <key>StandardErrorPath</key>
    <string>/tmp/codeherder-device-server.log</string>
</dict>
</plist>
```

`RunAtLoad` starts the server as soon as the plist is loaded (and at every subsequent login). `KeepAlive` restarts it automatically if it exits.

> **Using a credential profile instead:** Replace the `CH_TOKEN` and `CH_WORKSPACE_ID` entries in `EnvironmentVariables` with a single `CH_PROFILE` entry pointing to your named profile:
>
> ```
>     <key>CH_PROFILE</key>
>     <string>main</string>
> ```
>
> The profile file at `~/.codeherder/main.env` will be read at startup. See [Credentials and profiles](https://codeherder.com/docs/credentials/).

### 2. Load the agent

```
launchctl load ~/Library/LaunchAgents/com.codeherder.device-server.plist
```

The agent starts immediately and loads at every subsequent login.

### Useful commands

```
# Stop and unload the agent
launchctl unload ~/Library/LaunchAgents/com.codeherder.device-server.plist

# Reload after editing the plist
launchctl unload ~/Library/LaunchAgents/com.codeherder.device-server.plist
launchctl load ~/Library/LaunchAgents/com.codeherder.device-server.plist

# Follow live logs
tail -f /tmp/codeherder-device-server.log
```

## Docker container (trusted mode)

Running `ch device-server --docker` starts the device server inside a Docker container instead of directly on your machine. The container image is maintained for you and kept in sync with the CLI version automatically. Your home directory, SSH keys, and other machine credentials are not accessible from inside the container — only what CodeHerder needs is forwarded in.

`--docker` is the **trusted mode**. The container is privileged, and the agent has passwordless `sudo` and a world-writable Docker socket inside it. Every session shares one user, the device token, and the git credential pool. It is not a boundary against a hostile agent or a malicious repository. Set `CH_TRUSTED_EXECUTION=1` to consent to this mode. Without it, the device server exits at start. To run agents you do not trust, use `--docker-executor` instead (see [Isolating agent runs on a device](https://codeherder.com/docs/agent-isolation/)).

This mode wraps the whole device server and every stage it runs, as a single unit. If you’d rather keep running the device server directly on the host but isolate each *stage* into its own disposable container instead, see [Isolating agent runs on a device](https://codeherder.com/docs/agent-isolation/) — its `--docker-executor` mode is a different feature from `--docker` here, and the two cannot be combined.

### Prerequisites

- Docker installed and running on the machine.
- **Register on the host first.** Run `ch device-server` directly — without `--docker` — from a terminal once, and accept the registration prompt (see [How do I add a device?](https://codeherder.com/docs/adding-a-device/)). With that host token in place, `--docker` seeds it into the container automatically, so the container reuses that same device identity. Skip this and run `--docker` from a terminal anyway, and the container prints the same registration prompt from inside itself instead — it still registers, but the token then lives only in the container’s own storage, not at `~/.codeherder/device.token` on the host. Run it non-interactively (as a service) with no host token, though, and you get the same idle case as the non-Docker path: no prompt, no registration, no device.
- `CH_TOKEN` set to your CodeHerder API key (same as the non-Docker device server).
- `CH_TRUSTED_EXECUTION=1` set, to consent to the trusted mode. Without it the device server exits at start with a message that names this variable.
- `gh` or `glab` logged in on the host. The launcher reads your host git credentials and forwards them into the container, so the agent can push code without you configuring git auth inside the container separately.

### Run it

```
CH_TRUSTED_EXECUTION=1 ch device-server --docker
```

Or set the equivalent environment variable — useful in scripts and service files:

```
CH_TRUSTED_EXECUTION=1 CH_DOCKER=1 ch device-server
```

On start, the device server logs a warning that it runs in the trusted mode.

CodeHerder launches a container named `ch-device-server` by default and starts the device server inside it. A named volume keeps the device identity and any in-flight work across container restarts, so stopping and restarting the container does not lose your device registration or disrupt running sessions.

When the container exits it is removed automatically, so a stopped `ch device-server --docker` leaves no leftover container to clean up.

### Run in Docker under systemd

Add `CH_DOCKER=1` and `CH_TRUSTED_EXECUTION=1` to your systemd unit’s environment. Using the unit file from the **Linux: systemd user service** section above, the relevant block becomes:

```
[Service]
ExecStart=%h/.local/bin/ch device-server
Restart=on-failure
RestartSec=5
Environment="CH_TOKEN=<your-api-key>"
Environment="CH_WORKSPACE_ID=<your-workspace-id>"
Environment="CH_DOCKER=1"
Environment="CH_TRUSTED_EXECUTION=1"
```

Everything else in the unit file stays the same. systemd treats the container exit as a normal process exit, so `Restart=on-failure` brings the device server back up if the container stops unexpectedly. If you leave out `CH_TRUSTED_EXECUTION=1`, the device server logs a consent warning on every start; check `journalctl --user -u <unit>`. A later release will exit at once instead, and systemd will then restart it in a loop. Set the variable now to avoid that.

### Run in Docker under launchd

Add `CH_DOCKER` and `CH_TRUSTED_EXECUTION` to the `EnvironmentVariables` block of your launchd plist. Using the plist from the **macOS: launchd LaunchAgent** section above, the `EnvironmentVariables` dict becomes:

```
<key>EnvironmentVariables</key>
<dict>
    <key>CH_TOKEN</key>
    <string>your-api-key</string>
    <key>CH_WORKSPACE_ID</key>
    <string>your-workspace-id</string>
    <key>CH_DOCKER</key>
    <string>1</string>
    <key>CH_TRUSTED_EXECUTION</key>
    <string>1</string>
</dict>
```

Reload the agent after saving the plist for the change to take effect:

```
launchctl unload ~/Library/LaunchAgents/com.codeherder.device-server.plist
launchctl load ~/Library/LaunchAgents/com.codeherder.device-server.plist
```

### Running more than one on the same host

Each `--docker` device server is a singleton by default: it always launches a container named `ch-device-server` and reuses the same supporting Docker networks and volume. Starting a second default instance removes the first instance’s container to make room, because the launcher clears out any existing container of that name before it starts. If you want more than one Docker device server running on the same machine, give each one a distinct instance name.

Pass `--instance-name <name>`:

```
CH_TRUSTED_EXECUTION=1 ch device-server --docker --instance-name eu-worker
```

Or set the equivalent environment variable — useful in scripts and service files:

```
CH_TRUSTED_EXECUTION=1 CH_DOCKER_INSTANCE=eu-worker ch device-server --docker
```

If both are set, the `CH_DOCKER_INSTANCE` environment variable wins.

The instance name is appended as a suffix to the container and to its supporting Docker networks and volume, so each instance stays fully independent — for example, `--instance-name eu-worker` produces a container named `ch-device-server-eu-worker`. It must be 1–20 characters long and contain only letters, digits, and hyphens, and it must start and end with a letter or digit.

Each concurrent device server is still a separate device from CodeHerder’s point of view, so it needs its own device registration and token — see [What the device server needs at startup](https://codeherder.com/docs/running-the-device-server/#what-the-device-server-needs-at-startup) above for how to point a server at a specific token file with `--token-file`.

Add `CH_DOCKER_INSTANCE` alongside `CH_DOCKER` and `CH_TRUSTED_EXECUTION` in the systemd unit’s environment to give that instance its own name:

```
Environment="CH_DOCKER=1"
Environment="CH_TRUSTED_EXECUTION=1"
Environment="CH_DOCKER_INSTANCE=eu-worker"
```

The launchd equivalent adds the same variable to the plist’s `EnvironmentVariables` dict, alongside `CH_DOCKER`:

```
<key>CH_DOCKER_INSTANCE</key>
<string>eu-worker</string>
```

## Auto-update under a service manager

Auto-update is on by default when a release source is configured (`CH_CLI_MANIFEST_URL`, or the manifest URL stamped into the build; with neither it stays off). Roughly once an hour, `ch device-server` checks whether a newer `ch` release is available. When it finds one, it downloads and verifies the binary, then **re-execs itself in place**: the running process replaces itself with the updated binary without terminating.

**By default, in-flight agent sessions are not interrupted.** Before re-executing, the server detaches any running agent processes. The new binary starts up and re-adopts those sessions automatically — tasks continue without any action from you. See [Updating the CLI](https://codeherder.com/docs/updating/) for the opt-in flag that makes an update stop running agents instead.

Because re-exec replaces the process image rather than exiting, neither systemd nor launchd sees a restart event. The service manager continues supervising normally throughout the update.

To control auto-update behaviour, set these environment variables in your service file:

| Variable | Effect |
| --- | --- |
| `CH_DS_AUTO_UPDATE=0` | Disable automatic updates. |
| `CH_DS_AUTO_UPDATE_INTERVAL=6h` | Check every six hours instead of every hour. |

See [Updating the CLI](https://codeherder.com/docs/updating/) for the full set of auto-update options including the equivalent flags.

## Tuning session capacity and storage

By default, the device server stores session worktrees in `~/codeherder-sessions` and caps the total at 8 simultaneous worktrees. Three flags let you tune this on a self-managed device server:

| Flag | Default | What it controls |
| --- | --- | --- |
| `--session-root <abs-dir>` | `~/codeherder-sessions` | Parent directory for per-session git worktrees. Must be an absolute path. The **Session root** and **Disk headroom** readiness checks (visible in [Managing your devices](https://codeherder.com/docs/devices/)) report on this filesystem. |
| `--max-session-worktrees <n>` | `8` | Device-local cap on simultaneous session worktrees. The device runs at most the smallest of this value, its **Max concurrent sessions** (set in the web app), and the platform-wide ceiling — so raise this only after you have also raised the server-side limits and want more parallelism on a beefier machine. The **Session capacity** readiness check reports how close the device is to this cap. |
| `--session-disk-budget-mb <mb>` | `0` (count cap only) | Optional cap on total session-worktree disk in MB. When set to 0 (the default), only the worktree-count cap applies; when set, **Session capacity** also reports disk usage against this budget. |

See [Managing your devices](https://codeherder.com/docs/devices/) for how the per-device limit and platform-wide ceiling combine to set the running-session cap.

For the other directories that grow on a device, how to measure them, and the disk alert level, see [Self-hosted device disk growth](https://codeherder.com/docs/self-host-device-disk/).

The `CH_MAX_SESSION_WORKTREES` environment variable is an alternative to the `--max-session-worktrees` flag above — set it to a positive number and it overrides the flag, on every launch mode (terminal, systemd, launchd, or `--docker`). It’s handy in a service file where adding an environment entry is easier than editing the command line; under `--docker` it still unconditionally overrides whatever `--max-session-worktrees` value the container parses (see below).

### Applying these flags under a service manager

**Linux (systemd)** — append the flags to the `ExecStart=` line:

```
ExecStart=%h/.local/bin/ch device-server --session-root /data/codeherder-sessions --max-session-worktrees 4
```

Reload and restart the service after editing the unit file:

```
systemctl --user daemon-reload
systemctl --user restart codeherder-device-server
```

**macOS (launchd)** — add each flag as a separate `<string>` entry in the `ProgramArguments` array:

```
<key>ProgramArguments</key>
<array>
    <string>/Users/youruser/.local/bin/ch</string>
    <string>device-server</string>
    <string>--session-root</string>
    <string>/Users/youruser/codeherder-sessions</string>
    <string>--max-session-worktrees</string>
    <string>4</string>
</array>
```

Reload the plist after saving:

```
launchctl unload ~/Library/LaunchAgents/com.codeherder.device-server.plist
launchctl load ~/Library/LaunchAgents/com.codeherder.device-server.plist
```

**Docker (`ch device-server --docker`)** — in Docker mode, session storage lives on a named Docker volume the launcher manages, not the host filesystem, so `--session-root` has no effect: the launcher strips it from the flags it forwards into the container and instead pins the in-container session-root to the volume mount via `CH_SESSION_ROOT`. `--token-file` is host-side-only for the same class of reason — the launcher reads it on the host to seed the container’s device token, so it’s stripped too. `--max-session-worktrees` and `--session-disk-budget-mb`, by contrast, DO take effect under `--docker` — the launcher forwards them straight through to the in-container device-server. Use the environment variable below if you’d rather tune the worktree cap without editing the command line:

| Variable | Default | What it controls |
| --- | --- | --- |
| `CH_MAX_SESSION_WORKTREES` | `16` | Maximum simultaneous session worktrees inside the container. When set, it unconditionally overrides `--max-session-worktrees` (flag or no flag) — the same override precedence as every other launch mode. |

`--session-disk-budget-mb` has no environment-variable equivalent; set it as a flag on `ch device-server --docker <flags>` if you want a disk budget enforced inside the container.

`CH_MAX_SESSION_WORKTREES` is the same variable described above — it isn’t Docker-specific, and under `--docker` it’s just handier than editing the command line. To apply it under a service manager, add it to the environment block alongside `CH_DOCKER`, the same way environment variables are set in the **Linux: systemd** and **macOS: launchd** sections above.

## Confirm the device is Online

After the service starts, check that the device is connected:

```
ch device list
```

The device should show **Online** within a few seconds. If it doesn’t, the symptom tells you which of two problems you have:

- **The service keeps restarting, or the process won’t stay up.** This is the auth gate: no `CH_TOKEN` is set and `CH_PROFILE` doesn’t name a profile it can load, so the server exits immediately every time it starts. Fix the credential in your unit file or plist, then restart the service. (A `CH_TOKEN` that’s set but wrong or revoked passes this check — the server starts and only fails later, once it tries to actually talk to CodeHerder.)
- **The service stays running, but the device never shows up in `ch device list`.** The device token file was never written — the service started before you ever registered interactively, so there’s nothing for it to connect with, and it just idles. Register the device from a terminal first (see [How do I add a device?](https://codeherder.com/docs/adding-a-device/) and the prerequisite above), confirm it’s **Online**, then restart the service so it picks up the saved token.
- **The service runs and the device shows Online, but no work ever lands on it.** This is a personal device, most likely one `ch start` registered on this machine earlier. Promote it with `ch device enable <deviceId>` — see [Personal devices](https://codeherder.com/docs/devices/#personal-devices).

See the Troubleshooting section of [Managing your devices](https://codeherder.com/docs/devices/) for more on either case.

## Related guides

- [How do I add a device?](https://codeherder.com/docs/adding-a-device/) — the focused first-run steps to connect a machine
- [Managing your devices](https://codeherder.com/docs/devices/) — day-to-day device health, capacity, and troubleshooting
- [Monitoring your agents](https://codeherder.com/docs/monitoring-agents/) — watch fleet health once the service is running
- [Updating the CLI](https://codeherder.com/docs/updating/) — how the service keeps its own binary current
