This guide runs holmd on a Linux host as a service that other machines, agents, and MCP hosts can reach. It uses Docker, systemd, and a reverse proxy for TLS.
For the concepts, see The server and Runtimes.
1. Prepare the host
You need:
- A Linux host with Docker, or another runtime.
- A DNS name for the server, such as
boxes.example.com. - A reverse proxy that terminates TLS. This guide uses Caddy.
Plan the size of the host from the number of boxes that run at the same time. Each screen runs a display server, a window manager, Chromium, and a viewer. Measure one box on your host with docker stats before you decide.
2. Install
curl -fsSL https://raw.githubusercontent.com/holm-inc/holm/main/scripts/install.sh | HOLM_INSTALL_DIR=/usr/local/bin shMake a user for the service, and give it access to Docker:
sudo useradd --system --create-home --home-dir /var/lib/holm holm
sudo usermod -aG docker holmAccess to the Docker socket is equal to root access on the host. Run holmd on a host that is only for boxes.
3. Make the secrets
sudo install -d -m 0750 -o holm -g holm /etc/holm
openssl rand -hex 32 | sudo tee /etc/holm/token >/dev/null
sudo chmod 0600 /etc/holm/token
sudo chown holm:holm /etc/holm/tokenThe server token protects the API. Give it only to clients that can create and drive boxes.
If you will add cloud vendors with holm runtime add, the server also needs a key to encrypt their API keys. HOLM_SERVER_SECRET_FILE makes one at first start (step 4).
4. Configure
Write /etc/holm/holmd.env:
HOLM_SERVER_ADDR=127.0.0.1:8080
HOLM_PUBLIC_URL=https://boxes.example.com
HOLM_STORAGE_BACKEND=local
HOLM_STATE_DIR=/var/lib/holm/state
HOLM_SERVER_SECRET_FILE=/var/lib/holm/secret.key
HOLM_SERVER_CONFIG=/etc/holm/runtimes.toml
RUST_LOG=holmd=info,holm=infoHOLM_SERVER_ADDRstays on loopback, because only the proxy connects to it.localstorage keeps traces and remote runtimes across restarts. For more than one server, usepostgresors3. See Storage.RUST_LOGsets the log level.
The token goes into the environment in step 5, from its file.
Write /etc/holm/runtimes.toml:
default = "docker"
[runtimes.docker]
memory = "4g"
lifetime = "1h"
max_lifetime = "8h"Each box gets a lifetime, so a box that nobody removes does not run forever. See The runtimes file for all fields.
5. Run as a systemd service
Write /etc/systemd/system/holmd.service:
[Unit]
Description=holmd
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
[Service]
User=holm
Group=holm
EnvironmentFile=/etc/holm/holmd.env
ExecStart=/bin/sh -c 'HOLM_SERVER_TOKEN="$(cat /etc/holm/token)" exec /usr/local/bin/holmd'
KillSignal=SIGINT
TimeoutStopSec=30
Restart=on-failure
WorkingDirectory=/var/lib/holm
[Install]
WantedBy=multi-user.targetKillSignal=SIGINT is necessary. holmd shuts down cleanly and writes its store only on SIGINT. With the default SIGTERM, the last writes can be lost.
sudo systemctl daemon-reload
sudo systemctl enable --now holmd
journalctl -u holmd -fThe log shows holmd is listening with the runtimes it found.
6. Put a proxy in front
The proxy must forward /v1, /mcp, and WebSocket upgrades. The viewers and the MCP Apps live screen use WebSockets.
Caddy forwards WebSockets with no extra settings:
boxes.example.com {
reverse_proxy 127.0.0.1:8080
}For nginx, add the upgrade headers:
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 1h;
}A long read timeout keeps viewer connections open.
7. Check it
curl https://boxes.example.com/v1/health
export HOLM_SERVER_URL=https://boxes.example.com
export HOLM_SERVER_TOKEN="$(sudo cat /etc/holm/token)"
BOX=$(holm new --ttl 10)
holm screenshot "$BOX" check.png
holm rm "$BOX"The first box builds the image, which takes a few minutes.
8. Build images in advance
Each set of applications and packages is a different image. Build the images that your boxes use before the first request needs them:
holm image build docker
holm image build docker --app vscode --package jq
holm image ls9. Connect clients
CLI: set HOLM_SERVER_URL and HOLM_SERVER_TOKEN, as in step 7.
MCP host with a URL:
URL: https://boxes.example.com/mcp
Transport: Streamable HTTP
Authorization: Bearer <token>MCP host that starts a local process:
{
"mcpServers": {
"holm": {
"command": "holm",
"args": ["mcp", "--stdio"],
"env": {
"HOLM_SERVER_URL": "https://boxes.example.com",
"HOLM_SERVER_TOKEN": "<token>"
}
}
}
}Rust: use holm-client.
10. Let people see the boxes
A box publishes its own viewers only on the host's loopback address. From another machine:
- In an MCP Apps host, the live screen connects through
holmd, so it works with no more setup.HOLM_PUBLIC_URLmust be correct. - For viewer and takeover URLs, the box must publish its viewers with a credential. Set
policyin the box spec:
{
"spec": {
"policy": { "auth": "token", "bind": "any", "advertise": "boxes.example.com" }
}
}The host firewall must then allow the box's viewer ports, and these URLs do not use the proxy's TLS. See Viewer access.
Add a cloud vendor
printf '%s' "$E2B_API_KEY" | holm runtime add cloud --provider e2b --api-key
holm runtime lsFor Vercel, give the project and team as fields. See Vercel Sandbox.
holm runtime add vercel --provider vercel --field project_id=prj_... --field team_id=team_... --api-key-env VERCEL_TOKENThe server encrypts the key with the key file from step 4. Keep that file: if it is lost, the server cannot decrypt the vendor keys, and the runtime shows unavailable until you set the key again.
Upgrade
curl -fsSL https://raw.githubusercontent.com/holm-inc/holm/main/scripts/install.sh | HOLM_INSTALL_DIR=/usr/local/bin sh
sudo systemctl restart holmdBoxes continue to run while the server restarts. At start, the server takes back each box in its records and each box that an earlier server labeled. With local storage or a database, their traces continue.
A new release can change the image source. Boxes started after the upgrade use a new image, so build the images again (step 8).
Checklist
-
HOLM_SERVER_ADDRis on loopback, and only the proxy is public. - The server token is long and random, and only trusted clients have it.
- The service uses
KillSignal=SIGINT. - Storage is durable (
local,sqlite,postgres, ors3). - Each runtime has a
lifetimeand amax_lifetime. - The secret key file has a backup, if you store vendor keys.
- The proxy forwards WebSocket upgrades.
- The host is only for boxes, because the service user can use Docker.