Deploy containers, VMs, binaries, scripts, and cron jobs from your workstation to Linux hosts.
The normal way to deploy small infrastructure is to accidentally build a platform. You start with SSH, then add shell scripts, then add a deploy box, then add a secrets story, then add a dashboard, then discover that your dashboard is mostly a slower way to run SSH.
yeet tries not to do that.
You run yeet locally. It installs a small daemon called catch on a Linux host. After that, commands go over Tailscale to the host, and catch turns them into boring Linux things: systemd units, Docker Compose projects, containers, cron jobs, files, and VMs.
Not magic. Just fewer places for state to hide.
yeetrun.com · Quick Start · Host Setup · Docs
Use yeet when you have one or more Linux hosts and you want to run real services without turning your homelab into a miniature cloud provider.
Yeet can deploy:
- Docker Compose stacks
- Container images
- Local Dockerfiles
- Linux binaries
- Shell scripts
- Cron jobs
- Linux VMs on KVM-capable hosts
It fits single-operator homelabs and small private infrastructure. It expects
Linux hosts with systemd. New native binaries, scripts, and cron jobs run as
the unprivileged yeet-svc account by default; Docker and VM identities stay
in their own runtimes.
Yeet is for hosts you control. It is not a multi-tenant platform.
There are two moving parts:
yeet: the CLI on your workstation.catch: the daemon on each Linux host you manage.
First setup uses SSH:
yeet init root@<machine-host>After setup, normal commands target the catch host over Tailscale:
yeet status
yeet run <svc> ./compose.yml
yeet logs -f <svc>Use the machine hostname for yeet init. Use the catch hostname for normal yeet commands after setup. The default catch hostname is catch.
This gets you from nothing to a disposable container.
curl -fsSL https://yeetrun.com/install.sh | shNightly build:
curl -fsSL https://yeetrun.com/install.sh | sh -s -- --nightlyCheck it:
yeet --helpCatch joins your tailnet as a tagged device, usually tag:catch. User-owned catch nodes are rejected.
You need a Tailscale OAuth client secret. In the Tailscale admin console, go to:
Trust credentials -> Credential -> OAuth
For the first install, the simple path is broad access:
All - Read & Write
The tighter path is Auth Keys write access for the tag catch will use, usually tag:catch.
Your tailnet policy also needs to allow the setup user to reach catch on TCP 41548 with the yeetrun.com/app/yeet app permissions:
readmanagessh
First setup needs all three. Later, split them if you want narrower roles.
yeet init root@<machine-host>If you SSH as a non-root user, yeet runs the remote install with sudo.
Interactive setup asks for the Tailscale OAuth client secret. Catch stores its state in this directory by default:
/var/lib/yeet
The service root defaults to <data-dir>/services, which is
/var/lib/yeet/services with the default data directory. Set --data-dir or
--services-root during init when the host needs a different filesystem path.
Explicit custom roots are preserved during upgrades and guided migrations.
If Docker is missing on a Debian/Ubuntu-style host, interactive setup asks before installing it. If the host can run VMs, setup can ask about VM tools too.
If the host has ZFS and you want service data on datasets:
yeet init --zfs --data-dir=flash/yeet/data --services-root=flash/yeet/services root@<machine-host>Rerunning yeet init upgrades catch without changing explicit custom or ZFS
roots.
When an interactive upgrade finds the exact legacy home-directory layout, it
can offer to move that state to /var/lib/yeet. If init cannot prompt, run the
same migration explicitly:
yeet host set \
--data-dir=/var/lib/yeet \
--services-root=/var/lib/yeet/services \
--migrate-services=all \
--yes
yeet host cleanup --from=/root/yeet-data --yesyeet host set moves and validates the active state but does not delete the old
tree. Run cleanup separately. Cleanup refuses arbitrary paths, revalidates the
active Catch and service state, and removes only the journaled inactive source.
If deletion alone fails, rerun the same cleanup command to resume it safely.
ZFS datasets are not copied or deleted implicitly. Dataset-backed data and nested datasets stay in place unless you manage them explicitly.
yeet version
yeet statusIf you have more than one catch host:
yeet --host=<catch-host> statusSave a default:
yeet config --host=<catch-host>Yeet writes yeet.toml after a successful deploy. Put services in a directory you mean to keep.
mkdir -p ~/yeet-services
cd ~/yeet-servicesAfter setup, yeet can remember this workspace in $XDG_CONFIG_HOME/yeet/config.toml, so commands from other directories can still find the right yeet.toml.
If you already have a yeet.toml in the current directory, interactive commands
such as yeet status can offer to adopt that directory as a saved workspace.
This file is the local state that makes commands from other directories behave like they were run from the workspace.
yeet run -p 18080:80 hello nginx:alpine
yeet status hello
yeet logs helloCheck the published port from the catch host:
yeet ssh -- curl -fsS http://127.0.0.1:18080/ >/dev/nullRemove it:
yeet rm --clean helloRead the prompt. --clean deletes service data, including VM disks for VM services, and removes the local yeet.toml entry.
Run these from a service workspace.
yeet run --web
yeet run --web <svc>
yeet run --web <svc> ./compose.ymlyeet run <svc> ./compose.ymlyeet run -p 8080:80 <svc> nginx:alpineyeet run <svc> ./Dockerfileyeet docker push <svc> <local-image>:<tag> --runGOOS=linux GOARCH=amd64 go build -o ./bin/<svc> ./cmd/<svc>
yeet run <svc> ./bin/<svc>yeet run <svc> ./script.sh -- --app-flag valueyeet cron <svc> ./job.sh "0 9 * * *"New native binaries, scripts, and cron-style timers run as the managed
yeet-svc system account by default. Choose an existing host account with
--run-as=USER[:GROUP] when the workload needs it:
yeet run <svc> ./bin/<svc> --run-as=app:app
yeet cron <svc> ./job.sh "0 9 * * *" --run-as=backupDocker execution identities stay in Compose (user:), and VM host execution
uses the separate yeet-vm jailer account. Existing native services keep their
current identity until you migrate one explicitly:
yeet service set <svc> --run-as=yeet-svc
yeet service set <svc> \
--service-root=/var/lib/yeet/services/<svc> \
--copy \
--run-as=yeet-svcThe migration stops the native workload, verifies the service root, updates ownership and systemd definitions as one rollback-safe transaction, and then restores its prior running state. ZFS-backed roots remain on their configured dataset. Non-root native workloads cannot request privileged host ports below 1024; use a higher host port or keep that workload explicitly root-owned.
Custom service roots must live below host-controlled directories. Every parent
must be owned by root and must not be group- or world-writable; /srv/apps and
ZFS mountpoints are typical choices, while a workload-owned home directory is
rejected because the workload could replace paths while Catch operates on them.
For an operator-created account, systemd also applies that account's configured
supplementary groups. Review memberships such as docker before selecting it.
yeet ssh <svc> deliberately clears supplementary groups for a more restricted
service shell.
yeet vm images catalog
yeet run <vm> vm://ubuntu/26.04
yeet ssh <vm>Yeet launches Firecracker through the matching Firecracker jailer. Catch
prepares the VM's host resources as root, and the jailer runs the VMM as the
static, non-login yeet-vm host account. This host account is separate from
the VM guest login user and from native-service --run-as identities.
Yeet automatically creates yeet-vm on the first VM preparation or during an
upgrade that finds VMs. Custom data roots, custom service roots, and ZFS-backed
VM storage remain supported because Yeet derives their paths from stored
configuration.
The host Firecracker and jailer pair has its own lifecycle. It is separate from the guest root filesystem, guest packages, guest kernel, and guest login user. See what each VM has running, configured, staged, and available for rollback:
yeet vm runtime status
yeet vm runtime status <vm> --format=json-prettyRuntime policy is manual by default. yeet vm runtime update refreshes the
host runtime cache but does not stage or restart a VM. upgrade stages an exact
runtime for the next start; add --restart only when downtime is acceptable:
yeet vm runtime update
yeet vm runtime upgrade <vm>
yeet vm runtime upgrade <vm> --restart
yeet vm runtime rollback <vm> --restartA guest package upgrade cannot request a host runtime change. A normal guest
reboot can consume a runtime that an operator or host policy already staged,
but it cannot select or download one. Catch upgrades also leave running VMs
alone. The optional stage-on-restart policy stages promoted releases without
restarting VMs.
Create and restore a VM disk recovery point on a ZFS-backed VM:
yeet snapshots create <vm> --comment "before package upgrade"
yeet snapshots restore <vm> <snapshot> --stop --start --yesFor a running VM, catch pauses the guest while it takes one atomic ZFS snapshot of the disk, then resumes it. The snapshot is crash-consistent disk state, not guest memory or VMM runtime state. Raw-disk VMs cannot be snapshotted. Restore replaces the VM disk state only.
Service names created by yeet run must use lowercase letters, numbers, and dashes, start with a letter, and end with a letter or number.
After a deploy succeeds, rerun the saved service with:
yeet run <svc>Status:
yeet status
yeet status <svc>
yeet status <svc-a> <svc-b>
yeet info
yeet info <svc>Logs:
yeet logs <svc>
yeet logs -f <svc>Shells and commands:
yeet ssh
yeet ssh <svc>
yeet ssh -- uname -a
yeet ssh <svc> -- ls -laAfter yeet init, host and regular service shells use catch over Tailscale. They do not need your original host SSH key or host password. VM services still connect to the guest operating system with SSH keys.
Lifecycle:
yeet restart <svc>
yeet stop <svc>
yeet start <svc>
yeet rm <svc>yeet rm <svc> keeps service data by default and prompts before removing the local config entry. Add --clean only when you want the data gone too.
Use root@<machine-host> for yeet init.
Use catch hostnames for normal commands:
CATCH_HOST=<catch-host> yeet status
yeet --host=<catch-host> status
yeet status@<catch-host>
yeet run <svc>@<catch-host> ./compose.ymlFor a second catch host, choose a distinct catch hostname during setup:
yeet --host=morpheus-catch init root@<machine-host>Save the default:
yeet config --host=<catch-host>Yeet has a few network modes because services have different reachability and routing needs. Choose the mode that matches how the service should be reached.
--net=svc: private service network, yeet DNS, normal outbound internet through the catch host.--net=svc,ts:svcbehavior plus a service-owned Tailscale identity. Use this for most Tailscale-exposed services.--net=lan: LAN or VLAN address. Outbound internet comes from that network's DHCP gateway.--net=ts: tailnet-only unless you configure a Tailscale exit node.--net=iso: stable private address with public IPv4 egress, public-only DNS, and no workload-initiated access to catch, LAN,svc, Tailscale, or other isolated projects. Catch can still connect to the workload on any port.--net=iso,ts:isobehavior plus a service-owned Tailscale identity for supported container-backed payloads.
VM --net=lan attaches the guest TAP to a host bridge. On supported Debian/Ubuntu hosts, yeet can prepare br0 during yeet init or before the first VM LAN create.
ISO supports VMs and container-backed payloads. VMs use iso alone and can
install Tailscale inside the guest when needed. Native binaries, scripts, and
cron jobs cannot use ISO while they run as root-owned host services. ISO also
rejects published ports and unsafe Compose features instead of pretending they
are contained.
Read the docs before combining networking modes with real services. Future you is the person who has to debug it.
ZFS is optional.
If you use a ZFS services root, yeet treats it as a dataset prefix. Services under it use child datasets, which gives you snapshots and fast VM disk clones.
That is persistent storage, so read the ZFS docs first if the data matters.
Check local yeet and catch hosts:
yeet upgrade checkUpgrade from verified GitHub release assets:
yeet upgradeWhen run from a service workspace with yeet.toml, yeet upgrade includes all project catch hosts plus the default catch host.
Upgrade one host:
yeet upgrade --host=<catch-host>Force reinstall:
yeet upgrade --forceInstall the latest nightly release:
yeet upgrade --nightlyInstall a specific public release:
yeet upgrade --version v0.6.1 --force--nightly and --version select different targets, so use one of them per command.
Copy files:
yeet copy ./local-file <svc>:/path/in/service-data
yeet copy <svc>:/path/in/service-data ./local-fileSee events:
yeet events <svc>Stage a payload before applying it:
yeet stage --helpManage service settings:
yeet service --help
yeet env --help
yeet snapshots --help
yeet host --helpWorkstation:
yeet- Tailscale access to the catch host
Catch host:
- Linux with systemd
- Tailscale
- Docker, if you run container payloads
- x86_64 Linux,
/dev/kvm, TUN/TAP, and VM filesystem tools, if you run VMs - ZFS, only if you want ZFS-backed service roots or VM clones
- Quick Start
- Host Setup
- Service Workspace
- Payloads
- Networking
- VMs
- ZFS
- Workflows
- Command reference
- Troubleshooting
- FAQ
Use mise:
mise installBuild:
mise exec -- go build ./cmd/yeet
mise exec -- go build ./cmd/catchTest:
mise exec -- go test ./...Install hooks:
mise run install-githooksRun the normal quality gate:
mise run qualityYeet is for hosts you control.
It is not a multi-tenant service platform. The default yeet-svc account
reduces native workload privilege but is shared across those workloads, while
Catch and host-management helpers remain root-owned. Access is operation-scoped
through Tailscale app permissions, and that helps, but it does not turn your
homelab into a public cloud.
This is a tool for making private infrastructure easier to operate, not for making unsafe boundaries safe by naming them.
BSD 3-Clause. See LICENSE.