Introduction
Goblin Mode is a command-line tool that spins up a per-project development virtual machine (VM).
The goal: after you’ve run gob up, you can SSH in and start developing.
Goblin Mode is opinionated — it sets things up just the way the author likes them.
Here’s what Goblin Mode does when you run gob up:
- A Debian VM is started on Hetzner.
- Tailscale is set up for VPN connectivity.
- You can configure which ports to expose with
tailscale serve.
- You can configure which ports to expose with
- cloud-init configures the VM. Packages are installed with apt and cargo-binstall.
- The local git repository is pushed to the VM.
- The VM repo is added as a remote called
gobin the local repo, so you can sync changes back withgit fetch gob.
- The VM repo is added as a remote called
- Goblin Mode detects the programming language for your project and installs the toolchain.
- Supported languages: Rust (rustup/cargo) and Python (uv).
Once you’re done, destroy the VM with gob down. A snapshot is saved by default, so you can pick up where you left off.
Author
Goblin Mode was created by Miikka Koskinen. I’ve written about it here on my blog. It’s software that I made for myself.
License
MIT. See LICENSES and REUSE.toml for full details.
Getting started
Installation
There’s no stable release of Goblin Mode yet, so you’ll need to install from source:
git clone https://github.com/miikka/goblinmode.git
cd goblinmode
cargo install --path .
This puts the gob binary on your PATH.
Initial configuration
Create the config file at ~/.config/goblinmode/config.toml with your credentials.
- To create a Hetzner API token, open the Hetzner Console, choose a project, then go to Security → API tokens and click Generate API token. Goblin Mode needs Read & Write permissions.
- To create a Tailscale API key, open the Tailscale Admin Console, go to Settings → Keys, and click Generate access token…
[hetzner]
api_token = "hzn-..."
[tailscale]
api_key = "tskey-api-..."
If you’d prefer to keep the API tokens and keys in a password manager, see the _cmd fields in the Configuration section.
Usage
cd into a Git project directory and spin up a VM:
gob up # provision a VM and sync the project
gob mosh # connect to the VM with mosh
gob zed # open the remote repository in Zed
gob down # snapshot and destroy the VM when done
To see all subcommands, run gob help.
Configuration
Goblin Mode uses two config files:
- User config (
~/.config/goblinmode/config.toml) — API credentials, dotfiles, and VM defaults that apply to all projects. - Project config (
.config/goblinmode.tomlin the project root) — per-project settings checked into the repository.
User config (~/.config/goblinmode/config.toml)
Full example
[hetzner]
# Hetzner Cloud API token (required).
# Option A: plain text (simple, least secure)
api_token = "hzn-..."
# Option B: shell command — stdout becomes the token
api_token_cmd = "op read 'op://Personal/Hetzner/api token'"
[tailscale]
# Tailscale API key for removing old devices (required).
api_key = "tskey-api-..."
api_key_cmd = "security find-generic-password -a goblinmode -s tailscale-api-key -w"
# Tailscale auth key for joining the tailnet (optional).
# When omitted, goblinmode mints a one-time preauthorized key via the
# Tailscale API key (using the configured `tags`, if any).
auth_key = "tskey-auth-..."
auth_key_cmd = "op read 'op://Personal/Tailscale/auth key'"
# ACL tags applied to the VM when it joins the tailnet (optional).
tags = ["tag:goblinmode"]
[dotfiles]
# Git repo to clone as ~/dotfiles on the VM (optional).
repo = "git@github.com:yourname/dotfiles.git"
# Script to run after cloning, relative to ~/dotfiles (optional).
install = "./install.sh"
[vm]
# Extra APT packages to install on every VM (optional).
packages = ["jq", "ripgrep", "tmux"]
# Coding agents to install on every VM (optional).
# Supported values: "claude-code", "opencode", "pi"
coding_agents = ["claude-code"]
# Extra packages to install via cargo-binstall on every VM (optional).
binstall_packages = ["jj-cli"]
Secrets (API tokens and keys)
Secrets (api_token, api_key, auth_key) can be provided in three ways,
checked in this priority order:
- Environment variable — always wins if set and non-empty
- Plain text value — stored directly in the config file
_cmdfield — runs a shell command; stdout becomes the secret value
| Secret | Config field | _cmd field | Environment variable |
|---|---|---|---|
| Hetzner API token | hetzner.api_token | hetzner.api_token_cmd | HETZNER__API_TOKEN |
| Tailscale API key | tailscale.api_key | tailscale.api_key_cmd | TAILSCALE__API_KEY |
| Tailscale auth key | tailscale.auth_key | tailscale.auth_key_cmd | TAILSCALE__AUTH_KEY |
Environment variables use __ (double underscore) as a separator for nested
keys. Each _cmd is run with sh -c, so shell features (pipes,
substitutions) work. The output is trimmed of leading and trailing whitespace.
Reference
| Key | Type | Required | Description |
|---|---|---|---|
hetzner.api_token | string | yes* | Hetzner Cloud API token |
hetzner.api_token_cmd | string | yes* | Shell command to fetch the token |
tailscale.api_key | string | yes* | Tailscale API key |
tailscale.api_key_cmd | string | yes* | Shell command to fetch the API key |
tailscale.auth_key | string | no | Tailscale auth key for VM enrollment |
tailscale.auth_key_cmd | string | no | Shell command to fetch the auth key |
tailscale.tags | string[] | no | ACL tags applied to the VM |
dotfiles.repo | string | no | Git URL for dotfiles repo |
dotfiles.install | string | no | Install script path relative to ~/dotfiles |
vm.packages | string[] | no | Extra APT packages installed on the VM |
vm.coding_agents | string[] | no | Coding agents to install ("claude-code", "opencode", "pi") |
vm.binstall_packages | string[] | no | Extra packages installed via cargo-binstall (e.g. "jj-cli") |
* At least one of the plain-text or _cmd variants is required.
Project config (.config/goblinmode.toml)
Place this file at .config/goblinmode.toml in the root of your project and
commit it to the repository. It lets each project customize the VM it gets.
Full example
# Hetzner server type (default: "cx23").
# See https://www.hetzner.com/cloud for available types.
server_type = "cx33"
# Ports exposed via `tailscale serve` on the VM (optional).
serve_ports = [3000, 8080]
# Extra APT packages installed on this project's VM, in addition to
# the packages listed in the user config (optional).
packages = ["nodejs", "postgresql-client"]
# Extra cargo-binstall packages for this project's VM (optional).
binstall_packages = ["git-absorb"]
# Coding agents to install on this project's VM (optional).
# Supported values: "claude-code", "opencode", "pi"
coding_agents = ["claude-code"]
Reference
| Key | Type | Default | Description |
|---|---|---|---|
server_type | string | "cx23" | Hetzner server type for the VM |
serve_ports | integer[] | [] | Ports exposed via tailscale serve on the VM |
packages | string[] | [] | Extra APT packages installed on the VM, merged with user config vm.packages |
binstall_packages | string[] | [] | Extra packages installed via cargo-binstall, merged with user config vm.binstall_packages |
coding_agents | string[] | [] | Coding agents to install ("claude-code", "opencode", "pi"), merged with user config vm.coding_agents |
Watchdog
Scheduling gob watchdog on macOS (launchd)
To clean up forgotten VMs automatically, schedule gob watchdog with a
user-level launchd agent. The example below runs it every hour, on the hour.
-
Find the absolute path to your
gobbinary — launchd does not inherit your shellPATH:which gob # e.g. /Users/you/.cargo/bin/gob -
Create
~/Library/LaunchAgents/com.goblinmode.watchdog.plist. Replace the<string>values markedREPLACE_MEwith the path from step 1 and your home directory:<?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.goblinmode.watchdog</string> <key>ProgramArguments</key> <array> <string>REPLACE_ME/.cargo/bin/gob</string> <string>watchdog</string> <string>--max-age</string> <string>8</string> </array> <key>StartCalendarInterval</key> <dict> <key>Minute</key> <integer>0</integer> </dict> <key>StandardOutPath</key> <string>REPLACE_ME/Library/Logs/goblinmode-watchdog.log</string> <key>StandardErrorPath</key> <string>REPLACE_ME/Library/Logs/goblinmode-watchdog.log</string> <key>EnvironmentVariables</key> <dict> <key>PATH</key> <string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string> </dict> </dict> </plist> -
Load it (it will also start automatically on each login):
launchctl load ~/Library/LaunchAgents/com.goblinmode.watchdog.plist -
Tail the log to confirm it’s running:
tail -f ~/Library/Logs/goblinmode-watchdog.log
To stop it, run launchctl unload ~/Library/LaunchAgents/com.goblinmode.watchdog.plist.
To run it once on demand without waiting for the next tick, run launchctl start com.goblinmode.watchdog.
A note on secrets
Launchd jobs run with a restricted environment. If your config.toml
uses _cmd fields that depend on tools requiring a terminal or keychain
unlock (e.g. op, security), the watchdog may fail to authenticate.
The most reliable options for an unattended agent are:
- plain-text tokens in
~/.config/goblinmode/config.toml, or EnvironmentVariablesin the plist (e.g.HETZNER__API_TOKEN,TAILSCALE__API_KEY).
Either way, treat the plist file as sensitive and chmod 600 it if you
embed secrets.