Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.
  • 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 gob in the local repo, so you can sync changes back with git fetch gob.
  • 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 SecurityAPI 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 SettingsKeys, 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.toml in 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:

  1. Environment variable — always wins if set and non-empty
  2. Plain text value — stored directly in the config file
  3. _cmd field — runs a shell command; stdout becomes the secret value
SecretConfig field_cmd fieldEnvironment variable
Hetzner API tokenhetzner.api_tokenhetzner.api_token_cmdHETZNER__API_TOKEN
Tailscale API keytailscale.api_keytailscale.api_key_cmdTAILSCALE__API_KEY
Tailscale auth keytailscale.auth_keytailscale.auth_key_cmdTAILSCALE__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

KeyTypeRequiredDescription
hetzner.api_tokenstringyes*Hetzner Cloud API token
hetzner.api_token_cmdstringyes*Shell command to fetch the token
tailscale.api_keystringyes*Tailscale API key
tailscale.api_key_cmdstringyes*Shell command to fetch the API key
tailscale.auth_keystringnoTailscale auth key for VM enrollment
tailscale.auth_key_cmdstringnoShell command to fetch the auth key
tailscale.tagsstring[]noACL tags applied to the VM
dotfiles.repostringnoGit URL for dotfiles repo
dotfiles.installstringnoInstall script path relative to ~/dotfiles
vm.packagesstring[]noExtra APT packages installed on the VM
vm.coding_agentsstring[]noCoding agents to install ("claude-code", "opencode", "pi")
vm.binstall_packagesstring[]noExtra 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

KeyTypeDefaultDescription
server_typestring"cx23"Hetzner server type for the VM
serve_portsinteger[][]Ports exposed via tailscale serve on the VM
packagesstring[][]Extra APT packages installed on the VM, merged with user config vm.packages
binstall_packagesstring[][]Extra packages installed via cargo-binstall, merged with user config vm.binstall_packages
coding_agentsstring[][]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.

  1. Find the absolute path to your gob binary — launchd does not inherit your shell PATH:

    which gob
    # e.g. /Users/you/.cargo/bin/gob
    
  2. Create ~/Library/LaunchAgents/com.goblinmode.watchdog.plist. Replace the <string> values marked REPLACE_ME with 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>
    
  3. Load it (it will also start automatically on each login):

    launchctl load ~/Library/LaunchAgents/com.goblinmode.watchdog.plist
    
  4. 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
  • EnvironmentVariables in 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.